Files
ghostty/src/terminal/mem.zig
Mitchell Hashimoto 9156ada169 terminal: add cold-history compression pass
PageList could compress individual nodes but had no policy-level
operation for selecting pages that are safe to reclaim. The compressed
state therefore remained reachable only from tests.

Add a stateless pass that considers only complete history pages while
the viewport follows the active area. It gates work on supported
retained-mapping reclamation, reports attempts and retained bytes, and
leaves restoration lazy when a resize pulls compressed history back into
the active area.

Add a live scrollback-compression benchmark for measuring complete
PageList compression and restoration against saved VT corpora. The pass
still has no production callers, and ReleaseFast terminal-stream
comparisons remain within the existing throughput guardrail.
2026-07-09 09:47:12 -07:00

223 lines
9.1 KiB
Zig

//! Virtual-memory operations shared by terminal page owners.
//!
//! Terminal pages use page-aligned, page-multiple mappings. This module can
//! discard the physical pages behind one of those mappings without releasing
//! its virtual address range, then prepare the same range for reuse. It does
//! not allocate memory or decide which terminal pages should be discarded.
const std = @import("std");
const builtin = @import("builtin");
const assert = @import("../quirks.zig").inlineAssert;
const log = std.log.scoped(.terminal_mem);
/// What guarantee decommit must provide when the OS cannot discard a mapping.
pub const DecommitMode = enum {
/// The dirty prefix must read as zero after this call, even when physical
/// reclamation is unavailable. Bytes after the prefix must already be zero.
zero,
/// Physical-memory reclamation is required. Do not touch the mapping when
/// reclamation is unsupported or fails; report the failure to the caller.
strict,
};
/// Return whether this target can reclaim physical memory for `mode` while
/// retaining the mapping's virtual address range.
///
/// Test builds support both modes because `decommit` simulates reclamation by
/// clearing the supplied range. Runtime reclamation is intentionally limited
/// to 64-bit Linux and Darwin. Other targets must leave strict callers' memory
/// resident; zero mode still provides its documented memset fallback through
/// `decommit` even when this function returns false.
pub inline fn canReclaim(comptime mode: DecommitMode) bool {
// Both modes use the same retained-mapping primitives. Keeping the switch
// exhaustive makes additions to DecommitMode choose target support
// explicitly rather than inheriting it accidentally.
return switch (mode) {
.zero, .strict => supported: {
// Tests never call into the OS because their allocator ranges can
// share mappings with unrelated allocations. `decommit` simulates
// successful reclamation by zeroing the requested range instead,
// so both modes are always available to tests on every target.
if (builtin.is_test) break :supported true;
// Compression currently retains complete page mappings for its
// lifetime. Limit the initial runtime support to 64-bit address
// spaces where that virtual-memory cost is negligible and where
// the retained-mapping behavior has been validated.
if (builtin.target.ptrBitWidth() != 64) break :supported false;
// Linux provides MADV_DONTNEED, which immediately discards pages
// from a private anonymous mapping and faults them back as zeroes.
// Zig reaches this through the raw syscall path without libc.
if (builtin.target.os.tag == .linux) break :supported true;
// Darwin provides the paired MADV_FREE_REUSABLE/FREE_REUSE
// operations used below. Darwin requires libc independently of
// this feature, so using its madvise entry point adds no new
// dependency to libghostty-vt.
if (builtin.target.os.tag.isDarwin()) break :supported true;
// Other targets have no retained-mapping reclamation contract in
// this module. Zero mode can still clear through its memset
// fallback, but strict callers must leave their mapping resident.
break :supported false;
},
};
}
/// Discard physical pages while retaining a mapping's virtual address range.
///
/// The complete mapping must be page-aligned and a multiple of the minimum
/// system page size. `dirty_len` identifies the prefix whose contents may be
/// nonzero. Strict mode requires the complete mapping to be dirty because a
/// successful discard invalidates all of its contents.
///
/// The return value reports whether the OS accepted the reclamation request.
/// Test builds return true after simulating reclamation by zeroing dirty bytes.
/// In zero mode, the requested bytes are guaranteed to be zero regardless of
/// the return value.
pub fn decommit(
comptime mode: DecommitMode,
memory: []align(std.heap.page_size_min) u8,
dirty_len: usize,
) bool {
assert(memory.len > 0);
assert(@intFromPtr(memory.ptr) % std.heap.page_size_min == 0);
assert(memory.len % std.heap.page_size_min == 0);
assert(dirty_len <= memory.len);
if (comptime mode == .strict) assert(dirty_len == memory.len);
// Testing allocator ranges may share an OS mapping with unrelated memory,
// so madvise is not safe. Zeroing models the only content guarantee callers
// have after a successful discard.
if (comptime builtin.is_test) {
@memset(memory[0..dirty_len], 0);
return true;
}
// DONTNEED immediately reclaims private anonymous pages on Linux and
// faults them back as zero-filled pages. We deliberately avoid MADV_FREE:
// it does not reduce RSS until memory pressure and does not guarantee that
// the next read is zero.
if (comptime builtin.os.tag == .linux) {
if (std.posix.madvise(
memory.ptr,
memory.len,
std.posix.MADV.DONTNEED,
)) |_| return true else |err| {
log.warn("madvise(DONTNEED) failed err={}", .{err});
if (comptime mode == .strict) return false;
// Zero mode falls through to the memset below.
}
}
// FREE_REUSABLE removes the range from the Darwin process footprint while
// retaining its mapping. Zero mode clears its dirty prefix first because
// the kernel may preserve the contents. Strict mode avoids that write
// because its caller will replace the entire mapping after recommit.
if (comptime builtin.os.tag.isDarwin()) {
if (comptime mode == .zero) @memset(memory[0..dirty_len], 0);
if (std.posix.madvise(
memory.ptr,
memory.len,
std.posix.MADV.FREE_REUSABLE,
)) |_| return true else |err| {
switch (mode) {
.strict => {
log.warn("madvise(FREE_REUSABLE) failed err={}", .{err});
return false;
},
.zero => {
// Plain FREE can still reclaim the already-zero mapping
// under pressure and does not require a reuse pairing.
std.posix.madvise(
memory.ptr,
memory.len,
std.posix.MADV.FREE,
) catch {};
return false;
},
}
}
}
if (comptime mode == .zero) @memset(memory[0..dirty_len], 0);
return false;
}
/// Prepare a mapping previously passed to decommit for reuse.
///
/// Linux and test builds need no explicit operation. Darwin pairs
/// FREE_REUSABLE with FREE_REUSE so pages touched by the caller are accounted
/// to the process again. Failure does not invalidate the retained mapping, so
/// reuse can continue after logging the accounting failure.
pub fn recommit(memory: []align(std.heap.page_size_min) u8) void {
assert(memory.len > 0);
assert(@intFromPtr(memory.ptr) % std.heap.page_size_min == 0);
assert(memory.len % std.heap.page_size_min == 0);
if (comptime builtin.is_test) return;
if (comptime builtin.os.tag.isDarwin()) {
std.posix.madvise(
memory.ptr,
memory.len,
std.posix.MADV.FREE_REUSE,
) catch |err| {
log.warn("madvise(FREE_REUSE) failed err={}", .{err});
};
}
}
test "decommit with zero fallback clears the dirty prefix" {
const testing = std.testing;
const memory_len = 2 * std.heap.page_size_min;
const memory = try testing.allocator.alignedAlloc(
u8,
.fromByteUnits(std.heap.page_size_min),
memory_len,
);
defer testing.allocator.free(memory);
@memset(memory, 0xAA);
_ = decommit(.zero, memory, memory.len);
try testing.expect(std.mem.allEqual(u8, memory, 0));
// The tail is already zero by contract, so a partially dirty mapping only
// needs its dirty prefix cleared.
@memset(memory[0..1024], 0xAA);
_ = decommit(.zero, memory, 1024);
try testing.expect(std.mem.allEqual(u8, memory, 0));
}
test "strict decommit retains the mapping for recommit" {
const testing = std.testing;
const memory_len = 2 * std.heap.page_size_min;
const memory = try testing.allocator.alignedAlloc(
u8,
.fromByteUnits(std.heap.page_size_min),
memory_len,
);
defer testing.allocator.free(memory);
@memset(memory, 0xAA);
const original_ptr = memory.ptr;
const original_len = memory.len;
try testing.expect(decommit(.strict, memory, memory.len));
try testing.expectEqual(original_ptr, memory.ptr);
try testing.expectEqual(original_len, memory.len);
try testing.expect(std.mem.allEqual(u8, memory, 0));
recommit(memory);
@memset(memory, 0xBB);
try testing.expect(std.mem.allEqual(u8, memory, 0xBB));
}
test "test builds can reclaim retained mappings" {
const testing = std.testing;
try testing.expect(canReclaim(.zero));
try testing.expect(canReclaim(.strict));
}