Dixie is capped (max_inflight 2, queue_depth 8, queue_wait 20s) and never queued on llama-server. Streaming chat, thinking off, 45s fallback to clean. Digest and chunk-embedding caches are content-addressed SQLite (Honcho context is not cached). Recover uses the guarded Fetcher. guardian-star is refused in code.
8.5 KiB
HANDOFF: no page-retention hysteresis in Allocator.UintptrFree
Status: resolved 2026-08-06. Retention is now unconditional and bounded to
one empty page per size class — design option 1 below — combined with the
explicit flush of option 3, (*Allocator).Trim. The tests keep their full
Allocs/Mmaps/Bytes/regs assertions by calling Trim first; moving them
after Close would have made them vacuous, since Close ends with
*a = Allocator{}. Two corrections to what follows: the invariant is asserted
at 16 sites, not 8 (TestFree and TestMalloc were missed, and 6 of the 16 are
benchmarks using b), and retention is per size class, since a global pool
would need the per-page capacity that the memory.go TODO still tracks.
Everything below is the original diagnosis, kept for its measurements and
rationale. It describes the code as of 0a6f754.
The ask
UintptrFree returns a shared page to the OS the moment its last live slot is
freed. There is no hysteresis, so an allocation pattern whose live count for a
size class repeatedly crosses zero pays a full mmap/munmap round trip per
turnover — in the degenerate case, one mmap + three munmaps per single
alloc/free pair. Decide whether this allocator should retain empty pages, and
under what policy.
Root cause
memory.go:222-241, the tail of UintptrFree:
(*page)(unsafe.Pointer(pg)).used--
if (*page)(unsafe.Pointer(pg)).used != 0 {
return nil
}
... unlink every node of this page from a.lists[log] ...
if a.pages[log] == pg {
a.pages[log] = 0
}
...
return a.unmap(pg) // <-- unconditional, immediate
used == 0 means "no live slots", which is treated as "give the page back".
a.pages[log] (the current carve target) is cleared too, so the next allocation
of that size class must newSharedPage → mmap again.
Two amplifiers make each turnover cost more than one syscall pair:
mmap(size)(mmap_unix.go:29) asks the kernel forsize + pageSizeso it can align the result, then unmaps the misaligned head and the tail — up to two trimmunmaps permmap, on top of the one that releases the page. A 64 KiB page therefore costs a 128 KiB kernel request. Measured totals below show all threemunmaps in the synthetic case and an average of two under the real workload.- With
pageSizeLog = 16andmaxSlotSizeLog = pageSizeLog - 2, every size class up to 16 KiB uses shared pages (memory.go:262), so this covers the whole small/medium range, not just tiny allocations.
Why it has not bitten before
Long-lived consumers (sqlite et al.) keep a working set alive, so used rarely
reaches 0 for a hot size class — the page stays mapped and the free list does its
job. The pathology needs a consumer that repeatedly drains a size class.
Standalone reproducer
No other module needed. Allocate one slot and free it, in a loop:
a := &memory.Allocator{}
defer a.Close()
for i := 0; i < n; i++ {
p, _ := a.UintptrMalloc(size)
a.UintptrFree(p)
}
n = 200000, strace -c -f -e trace=mmap,munmap, AMD Ryzen 9 3900X, go1.26.5:
| size | build | mmap |
munmap |
syscall time | end state |
|---|---|---|---|---|---|
| 64 B | v1.11.0 | 200,022 | 600,000 | 4.33 s | Mmaps=0 Bytes=0 |
| 64 B | + retention | 25 | 3 | 0.0002 s | Mmaps=1 Bytes=65536 |
| 4096 B | v1.11.0 | 200,024 | 600,000 | 4.49 s | Mmaps=0 Bytes=0 |
| 4096 B | + retention | 24 | 2 | ~0 | Mmaps=1 Bytes=65536 |
That is ~21.6 µs of kernel time per alloc/free pair, and it is size-independent across the shared-page range.
The consumer that hit it
modernc.org/libquickjs v0.13.1, which tracks QuickJS release 2026-06-04
(bellard/quickjs 3d5e064). That release added a small-block arena allocator:
31 size classes from 16 to 512 bytes, carved out of JS_MALLOC_ARENA_SIZE
= 4096-byte arenas, and it frees an arena as soon as its last block is
freed. QuickJS's free-on-empty policy sits directly on top of this package's
free-on-empty policy and the two resonate: 4096 → log 12 → shared page path,
15 arenas per 64 KiB page, cycling constantly.
V8 v8-v7 benchmark scores (throughput, higher is better), medians of 3
interleaved rounds on a quiet host, go1.26.5, libc v1.74.4:
| bench | libquickjs v0.12.10 (QuickJS 2025-09-13) |
libquickjs v0.13.1 (QuickJS 2026-06-04) |
v0.13.1 + retention |
|---|---|---|---|
| Richards | 175.5 | 243.5 | 243 |
| DeltaBlue | 208.5 | 16 | 265.5 |
| Crypto | 118.5 | 157.5 | 166 |
| RayTrace | 285.5 | 195.5 | 464.5 |
| EarleyBoyer | 465.5 | 618.5 | 622.5 |
| RegExp | 99.5 | 189 | 190 |
| Splay | 911.5 | 340.5 | 1149 |
| NavierStokes | 208.5 | 322 | 323 |
| Score | 240 | 187.5 | 348.5 |
Retention moves only the three allocation-bound benchmarks and leaves the other five within noise (243.5→243, 189→190, 322→323), which is what makes the attribution airtight.
Syscall counts and profile for DeltaBlue alone:
| build | mmap |
munmap |
syscall time |
|---|---|---|---|
| v0.12.10 (no arena allocator) | 302 | 532 | 0.0001 s |
| v0.13.1 | 1,345,702 | 2,691,328 | 21.1 s |
| v0.13.1 + retention | 72 | 62 | 0.0008 s |
10.43s 71.73% internal/runtime/syscall/linux.Syscall6
2.22s 15.27% modernc.org/memory.(*Allocator).mmap (62.79% cum)
0.03s 0.21% golang.org/x/sys/unix.munmap (58.94% cum)
72% of a JavaScript benchmark's runtime in mmap/munmap. Note the third
column of the score table: with retention the new QuickJS overtakes the old one
everywhere, which is the expected outcome — the arena allocator is a win once it
stops thrashing.
The experiment (proof of diagnosis, NOT a shippable patch)
Retain one empty page per size class by recycling it as the carve target instead of unmapping it:
if a.pages[log] == pg {
a.pages[log] = 0
}
+ if a.pages[log] == 0 {
+ (*page)(unsafe.Pointer(pg)).brk = 0
+ (*page)(unsafe.Pointer(pg)).used = 0
+ a.pages[log] = pg
+ return nil
+ }
if counters {
a.Bytes -= (*page)(unsafe.Pointer(pg)).size
}
return a.unmap(pg)
That is the whole change behind every "+ retention" number above. Its bound is one 64 KiB page per size class per allocator (≤ 64 classes → ≤ 4 MiB worst case, in practice only for classes actually used).
Why it cannot be merged as written
all_test.go asserts, after freeing everything, at 8 sites (:137, :203,
:285, :307, :331, :401, :466, :544):
if alloc.Allocs != 0 || alloc.Mmaps != 0 || alloc.Bytes != 0 || len(alloc.regs) != 0 {
Retention breaks Mmaps, Bytes and len(regs) by construction — visible in
the reproducer's end-state column, and go test ./... fails with the patch
applied. This is a policy conflict, not a leak: Close() still walks
a.regs and unmaps everything, so nothing escapes the allocator's lifetime. But
the invariant "every byte is back with the OS after the last Free" is real,
is asserted by those tests, and consumers may rely on it. Changing it is your
call.
Design options
- Unconditional bounded retention (the diff above). Simplest, helps every
consumer automatically, no API change. Costs the invariant; the tests would
need to assert after
Close, or a flush would need exposing. - Opt-in. An
Allocatorfield or constructor knob, default off, leaving existing behaviour and tests untouched solibquickjs/libccan turn it on. Safest, but each consumer has to know to ask. - Explicit flush. Always retain; add
(*Allocator).Trim()and let callers decide when to give pages back. - Bounded hysteresis, documented. As (1), but with the retention count an explicit, documented parameter rather than incidentally "one".
Open questions worth settling at the same time:
- Per size class (as prototyped) or one global pool of empty pages? The latter caps total retention regardless of how many classes are hot.
- Is the align-by-overallocating in
mmapworth revisiting? It doubles the syscall cost of every page acquisition, so it amplifies exactly this problem.
Provenance
Found while investigating why modernc.org/libquickjs did not show the +42% that
QuickJS 2026-06-04 advertises. Native C on the same host gains +60.8% on v8-v7
(+43.5% with DIRECT_DISPATCH 0, the configuration the ccgo build corresponds
to); the Go build instead regressed 21.7%. This allocator interaction was the
sole cause: with retention the Go build gains +45.2%, matching the native +43.5%,
and five of the eight benchmarks had been gaining 32–91% all along.