mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-07-10 03:09:42 +00:00
terminal: return free-listed page memory to the OS
The PageList page pool never returns memory to the OS: destroyed pages are zeroed and free-listed until the surface exits. Any operation that shrinks the page count (clearing scrollback, pruning churn, resets, reflow) therefore retains its high-water RSS forever. Clearing a full scrollback keeps all of it resident, which at the default 10MB scrollback-limit is 10MB per terminal of memory that can never be used again for anything else. Lots of memes on the internet about this, and it turns out operating systems give us an answer for this (both Linux and macOS at least), so let's do it kids. Pool items can't be individually freed since they live inside arena chunks, but they are page-aligned and page-multiple sized, so we can decommit them while they sit in the free list. Our page-aligned allocation pays off, again! On Linux, madvise(MADV_DONTNEED) reclaims the pages immediately and guarantees zero-fill on the next touch, which also lets us skip the zeroing memset entirely, making destroy cheaper. On macOS, we zero in place and mark the item MADV_FREE_REUSABLE, which removes it from the process footprint immediately. Reuse is paired with MADV_FREE_REUSE when the pool hands a buffer back out so that footprint accounting stays correct. The zero invariant required by page reuse holds either way: reusable page contents are either preserved (our zeroes) or reclaimed and zero-filled by the kernel. Other platforms and test builds keep the existing memset behavior. ## LLM Notes Fable 5 found the retention behavior while re-analyzing scrollback memory, wrote the change and tests, and verified the madvise semantics empirically with memory probes on macOS and a real Linux kernel, plus before/after throughput benchmarks on both. I reviewed the analysis, the diff, rewrote the code to be more idiomatic Zig, and wrote this commit message you're reading.
This commit is contained in:
@@ -480,7 +480,9 @@ fn initPages(
|
||||
|
||||
const page_buf = if (pooled) buf: {
|
||||
try tw.check(.page_buf_std);
|
||||
break :buf try pool.pages.create();
|
||||
const buf = try pool.pages.create();
|
||||
recommitPoolItem(buf);
|
||||
break :buf buf;
|
||||
} else buf: {
|
||||
try tw.check(.page_buf_non_std);
|
||||
break :buf try page_alloc.alignedAlloc(
|
||||
@@ -3483,14 +3485,15 @@ inline fn createPageExt(
|
||||
// Our page buffer comes from our standard memory pool if it
|
||||
// is within our standard size since this is what the pool
|
||||
// dispenses. Otherwise, we use the heap allocator to allocate.
|
||||
const page_buf = if (pooled)
|
||||
try pool.pages.create()
|
||||
else
|
||||
try page_alloc.alignedAlloc(
|
||||
u8,
|
||||
.fromByteUnits(std.heap.page_size_min),
|
||||
layout.total_size,
|
||||
);
|
||||
const page_buf = if (pooled) buf: {
|
||||
const buf = try pool.pages.create();
|
||||
recommitPoolItem(buf);
|
||||
break :buf buf;
|
||||
} else try page_alloc.alignedAlloc(
|
||||
u8,
|
||||
.fromByteUnits(std.heap.page_size_min),
|
||||
layout.total_size,
|
||||
);
|
||||
errdefer if (pooled)
|
||||
pool.pages.destroy(page_buf)
|
||||
else
|
||||
@@ -3558,9 +3561,12 @@ fn destroyNodeExt(
|
||||
.pool => {
|
||||
assert(page.memory.len <= std_size);
|
||||
|
||||
// Reset the memory to zero so it can be reused
|
||||
@memset(page.memory, 0);
|
||||
pool.pages.destroy(@ptrCast(page.memory.ptr));
|
||||
// Reset the memory to zero (and decommit it, where
|
||||
// supported) so it can be reused.
|
||||
const item: *align(std.heap.page_size_min) [std_size]u8 =
|
||||
@ptrCast(@alignCast(page.memory.ptr));
|
||||
decommitPoolItem(item, page.memory.len);
|
||||
pool.pages.destroy(item);
|
||||
},
|
||||
|
||||
.heap => {
|
||||
@@ -3572,6 +3578,100 @@ fn destroyNodeExt(
|
||||
pool.nodes.destroy(node);
|
||||
}
|
||||
|
||||
/// Zero a pool item buffer that is about to be returned to the pool
|
||||
/// free list and, where supported, tell the OS it can reclaim the
|
||||
/// backing memory while the buffer sits unused. Without the decommit,
|
||||
/// a pool that shrinks (scrollback clears, pruning churn, resets)
|
||||
/// retains its high-water RSS forever, since MemoryPool free lists
|
||||
/// never return memory to the OS.
|
||||
///
|
||||
/// dirty_len is the length of the page that lived in this item, which
|
||||
/// may be smaller than the item; bytes beyond it are already zero.
|
||||
///
|
||||
/// The invariant required for page reuse (see createPageExt): after
|
||||
/// this call the entire item reads back as zeroes. The pool writes its
|
||||
/// free list node into the first pointer-size bytes afterwards, which
|
||||
/// is safe because page initialization always overwrites them (see the
|
||||
/// comptime assert in Page).
|
||||
fn decommitPoolItem(
|
||||
item: *align(std.heap.page_size_min) [std_size]u8,
|
||||
dirty_len: usize,
|
||||
) void {
|
||||
// In test builds the buffer comes from std.testing.allocator, not
|
||||
// the OS page allocator, so madvise is neither safe nor meaningful.
|
||||
if (comptime builtin.is_test) {
|
||||
@memset(item[0..dirty_len], 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// MADV_DONTNEED on a private anonymous mapping immediately
|
||||
// reclaims the pages and guarantees they read back zero-filled
|
||||
// on the next access, so we can skip the memset entirely.
|
||||
//
|
||||
// We use MADV_DONTNEED rather than MADV_FREE deliberately:
|
||||
// MADV_FREE doesn't reduce RSS until memory pressure (invisible
|
||||
// to users watching memory usage) and doesn't guarantee zeroes
|
||||
// on the next read. MADV_DONTNEED's synchronous TLB-shootdown
|
||||
// cost only matters at allocator-level call frequency; page
|
||||
// destroys here are rare (clears, reflow, capacity changes), and
|
||||
// the hottest reuse path (grow's prune-reuse) doesn't go through
|
||||
// this function at all.
|
||||
if (comptime builtin.os.tag == .linux) {
|
||||
if (std.posix.madvise(
|
||||
item,
|
||||
std_size,
|
||||
std.posix.MADV.DONTNEED,
|
||||
)) |_| return else |err| {
|
||||
log.warn("madvise(DONTNEED) failed err={}", .{err});
|
||||
// Fall through to the memset below.
|
||||
}
|
||||
}
|
||||
|
||||
// On Darwin we zero in place and then mark the memory as
|
||||
// reusable, which removes it from the process footprint until
|
||||
// it is touched again. Contents of reusable memory are either
|
||||
// preserved (our zeroes) or reclaimed and zero-filled on the
|
||||
// next access, so the invariant holds either way. The reuse
|
||||
// side calls MADV_FREE_REUSE to fix up footprint accounting
|
||||
// (see recommitPoolItem).
|
||||
if (comptime builtin.os.tag.isDarwin()) {
|
||||
@memset(item[0..dirty_len], 0);
|
||||
std.posix.madvise(
|
||||
item,
|
||||
std_size,
|
||||
std.posix.MADV.FREE_REUSABLE,
|
||||
) catch {
|
||||
// Best-effort: plain MADV_FREE reclaims under memory
|
||||
// pressure only and needs no reuse pairing.
|
||||
std.posix.madvise(
|
||||
item,
|
||||
std_size,
|
||||
std.posix.MADV.FREE,
|
||||
) catch {};
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
@memset(item[0..dirty_len], 0);
|
||||
}
|
||||
|
||||
/// The counterpart to decommitPoolItem for buffers handed back out by
|
||||
/// the pool. Only Darwin requires this: MADV_FREE_REUSE re-accounts
|
||||
/// previously reusable memory to the process footprint. Without it the
|
||||
/// memory still works (touching reusable pages revives them) but
|
||||
/// footprint reporting can undercount. Harmless on buffers that were
|
||||
/// never marked reusable (fresh or preheated items).
|
||||
fn recommitPoolItem(item: *align(std.heap.page_size_min) [std_size]u8) void {
|
||||
if (comptime builtin.is_test) return;
|
||||
if (comptime builtin.os.tag.isDarwin()) {
|
||||
std.posix.madvise(
|
||||
item,
|
||||
std_size,
|
||||
std.posix.MADV.FREE_REUSE,
|
||||
) catch {};
|
||||
}
|
||||
}
|
||||
|
||||
/// Fast-path function to erase exactly 1 row. Erasing means that the row
|
||||
/// is completely REMOVED, not just cleared. All rows following the removed
|
||||
/// row will be shifted up by 1 to fill the empty space.
|
||||
@@ -14344,6 +14444,66 @@ test "PageList compact oversized page" {
|
||||
}
|
||||
}
|
||||
|
||||
test "PageList decommitPoolItem zeroes the dirty region" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
// Note: in test builds decommitPoolItem always takes the memset
|
||||
// path; the madvise-based paths rely on kernel zero-fill semantics
|
||||
// that unit tests cannot exercise (see decommitPoolItem).
|
||||
const buf = try alloc.alignedAlloc(
|
||||
u8,
|
||||
.fromByteUnits(std.heap.page_size_min),
|
||||
std_size,
|
||||
);
|
||||
defer alloc.free(buf);
|
||||
const item: *align(std.heap.page_size_min) [std_size]u8 = @ptrCast(buf.ptr);
|
||||
|
||||
// Fully dirty item.
|
||||
@memset(item, 0xAA);
|
||||
decommitPoolItem(item, std_size);
|
||||
try testing.expect(std.mem.allEqual(u8, item, 0));
|
||||
|
||||
// Partially dirty item: an item that hosted a page smaller than
|
||||
// the item is only dirty up to the page length; the rest is zero
|
||||
// by invariant and must remain zero.
|
||||
@memset(item[0..1024], 0xAA);
|
||||
decommitPoolItem(item, 1024);
|
||||
try testing.expect(std.mem.allEqual(u8, item, 0));
|
||||
}
|
||||
|
||||
test "PageList destroyed pool page reuse is zeroed" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var s = try init(alloc, 80, 24, null);
|
||||
defer s.deinit();
|
||||
|
||||
// Create a page and scribble over its entire backing memory,
|
||||
// then destroy it so the buffer returns to the pool free list.
|
||||
const node = try s.createPage(.{ .cap = initialCapacity(80) });
|
||||
node.data.size.rows = 1;
|
||||
const mem_ptr = node.data.memory.ptr;
|
||||
@memset(node.data.memory, 0xAA);
|
||||
s.destroyNode(node);
|
||||
|
||||
// Reusing the buffer must produce a fully valid, zeroed page.
|
||||
const node2 = try s.createPage(.{ .cap = initialCapacity(80) });
|
||||
try testing.expectEqual(mem_ptr, node2.data.memory.ptr);
|
||||
node2.data.size.rows = node2.data.capacity.rows;
|
||||
|
||||
const cells_len = @as(usize, node2.data.capacity.cols) *
|
||||
@as(usize, node2.data.capacity.rows);
|
||||
const cells = node2.data.cells.ptr(node2.data.memory)[0..cells_len];
|
||||
try testing.expect(std.mem.allEqual(
|
||||
u64,
|
||||
@as([]const u64, @ptrCast(cells)),
|
||||
0,
|
||||
));
|
||||
node2.data.assertIntegrity();
|
||||
s.destroyNode(node2);
|
||||
}
|
||||
|
||||
test "PageList increaseCapacity from zero-capacity dimensions" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
Reference in New Issue
Block a user