Optimize anonymous mmap and page-fault paths#203
Conversation
Anonymous memory is reworked on a lazy foundation: sys_mmap only records the region, while page tables, zeroing and host-memory commit are deferred to first touch. Two optimization layers sit on top. mmap: the common shape (addr == 0, MAP_PRIVATE|MAP_ANONYMOUS, RW) is served entirely at EL1 with no vCPU exit and no lock. The shim stays stateless: it bump-allocates from a host-carved per-vCPU VA arena and reports each grant through a per-vCPU SPSC ring that the host drains whenever it takes mmap_lock, so no cross-vCPU synchronization exists. Arenas recycle freed address space through the gap allocator, refill automatically when a request does not fit (the new arena sized to cover the triggering request, with an ARENA_MAX guard so oversized requests fall back without churning the arena), and decay back to workload scale based on served-length history. Page fault: a per-2MiB-block dirty map lets materialization skip zeroing blocks whose slab bytes are known zero, moving the host page commit off the fault and out of mmap_lock onto the guest's own first writes. The TLBI protocol emits a single range op per materialized block instead of per-page invalidations, and a repeat fault on an already-valid block returns without re-zeroing. Host-side access to untouched lazy memory (I/O buffers, futex words, signal frames, shmat/shmdt copies) materializes on demand with lock-order-safe pre-faulting, and PROT_NONE stays a pure faulting reservation. tests/test-mmap-lazy.c and tests/test-mmap-fastpath.c pin the contract: zero-on-reuse, huge sparse reservations, host access to untouched memory, PROT_NONE round trips, fork, racing first touch, and arena refill/revocation. Close sysprog21#165
|
Provide comprehensive benchmarks. |
| (unsigned long long) arena_base, | ||
| (unsigned long long) arena_limit); | ||
| } | ||
| if (guest_region_add_ex(g, addr, addr + len, |
There was a problem hiding this comment.
The drain fails closed only on ring corruption, arena-bounds violation, and table-full. guest_region_add_ex_owned_gpa (src/core/guest.c:2106) inserts a sorted region with no overlap check, so if a bad arena base or the cursor race in the fast path ever produces an overlapping grant, this path silently inserts a duplicate region over live VA instead of detecting the invariant break. Adding an overlap assertion here (the insertion neighbor is already known via region_lower_bound_start) turns a silent corruption into a fail-closed abort -- cheap defense-in-depth for the fast path's "EL1 only bump-allocates disjoint VA" contract.
This comment was marked as outdated.
This comment was marked as outdated.
|
I have a follow-up branch elfuse-mmap-eval with two changes on top of the mmap and page-fault work here, plus a reusable mmap microbenchmark. 1. mremap: preserve neighbor PTEs on cross-block extend (correctness)
I also dropped a fresh-range invalidate-skip micro-optimization after A/B benchmarking showed it was a no-op: the invalidate it skipped is already free on an unmapped range (nothing to walk), at every size from 4 KiB to 32 GiB. 2. CNTKCTL_EL1 on worker vCPUs (perf bug the benchmark surfaced)Bootstrap sets
~315x on worker threads. Static glibc guests SVC Benchmark (
|
Deferred EL1
|
| Size | Fixed ops | p50 | p95 | max |
|---|---|---|---|---|
| 64 MiB | 32 | 3.60 us | 16.53 us | 19.07 us |
| 256 MiB | 24 | 5.37 us | 8.13 us | 45.57 us |
| 1 GiB | 8 | 2.01 us | 7.97 us | 8.99 us |
The 1 GiB case uses only eight operations because dirtying 24 GiB page by page
would hit elfuse's 10-second vCPU watchdog. Its p95 should therefore be treated
as directional because of the small sample count. The important distinction
is:
- p50: normal EL1 descriptor/TLBI fast-path latency;
- p95: common TLBI-completion variation; and
- max: rare TLBI, host-scheduling, or ring-fallback long tails.
Dirty stores must remain outside the timed interval. Their page faults are
natural VM exits that drain the previous retirement before the next timed
operation. Section H therefore measures one guest-visible munmap, not a
continually accumulating retire ring.
Correctness Invariants
Implementation and review should continuously verify the following
invariants:
- No PTE may be modified without a reserved retire slot.
- The retire tail may not be published, and EL0 may not resume, before the
broadcast TLBI has completed. - Before the host can observe a retire record, both the entry and translation
retirement must have completed. - Every metadata-reading host path must drain first.
- Mmap publications must commit before snapshotted retirements.
- A retired VA may not be reallocated before host commit.
- Before fresh backing replacement, the target must have no live PTE or
external alias, and sibling vCPUs must be quiesced. - Replacement failure must fall back safely and must not leave an HVF segment
detached.
Together, these invariants distinguish two moments: munmap() return means
that the guest translation has been revoked; host drain completion means that
metadata, backing, and VA reuse have all committed.
Implementation Locations and Validation
On the elfuse-mmap-eval branch, the primary code is located in:
src/core/mmap-fastpath.h: per-vCPU mmap/retire-ring ABI, the 32-entry ring,
and the 256 MiB pending-byte advisory.src/core/shim.S: EL1 validation, the L2-only scan, descriptor clearing,
scaledRVAE1IS, and release publication.src/syscall/mem.c: the PT gate, cross-ring drain ordering, metadata commit,
and the 32 MiB backing-replacement policy.src/syscall/proc.c: draining after every natural HVF return and before
dispatch.tests/test-mmap-fastpath.c: cross-vCPU handoff and immediate SCALE=3 TLBI
invalidation.tests/test-mmap-lazy.c: large backing replacement, neighbor preservation,
zero reuse, and fork snapshots.
In addition to functional tests, section H of tests/bench-mmap.c should retain
its fixed operation counts and per-operation latency statistics so that
adaptive sampling cannot again misreport a single long tail as the steady-state
cost for an entire size.
This comment was marked as outdated.
This comment was marked as outdated.
There was a problem hiding this comment.
3 issues found across 33 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="tests/test-mmap-lazy.c">
<violation number="1" location="tests/test-mmap-lazy.c:601">
P2: Early return from `test_mt_first_touch` on `pthread_create` failure leaks already-created threads, the barrier, and the mapping. In contrast, `test-mprotect-mt.c` properly joins created threads, signals termination, and unmaps on the same failure path.</violation>
<violation number="2" location="tests/test-mmap-lazy.c:694">
P1: Unchecked `pthread_create` return value in `test_claim_mutation_race` — if any of the 5 threads fails to create, the barrier will wait forever for the missing thread (deadlock), and joining an uninitialized `pthread_t` is undefined behavior. Both `test_mt_first_touch` in this same file and every other test file (e.g. `test-mprotect-mt.c`, `test-mmap-fastpath.c`) check the return.</violation>
</file>
<file name="src/runtime/futex.c">
<violation number="1" location="src/runtime/futex.c:1527">
P2: Pre-fault materializes lazy pages for unaligned futex addresses before the alignment check inside each command handler can return -EINVAL. The `guest_lazy_faultin(g, uaddr, sizeof(uint32_t))` call at the top of `sys_futex` runs before any alignment validation. If a caller passes an unaligned `uaddr` that happens to fall on a lazy anonymous page, the pre-fault materializes that page (allocating host memory, installing PTEs) as a side effect of what should be a pure -EINVAL return path. Linux rejects the unaligned futex word without touching any page. The same risk applies to `uaddr2` for FUTEX_REQUEUE, FUTEX_CMP_REQUEUE, and FUTEX_WAKE_OP. The comment block already shows awareness of side-effect concerns (unsupported commands are rejected before the pre-fault), making the gap for alignment more notable. Consider moving an alignment check before the pre-fault, or deferring the pre-fault into each case after its existing alignment guard.</violation>
</file>
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Re-trigger cubic
| int error = 0; | ||
| for (int i = 0; i < MT_THREADS; i++) { | ||
| args[i] = (claim_race_arg_t) {q, half, i, &barrier, &error}; | ||
| pthread_create(&workers[i], NULL, claim_race_touch, &args[i]); |
There was a problem hiding this comment.
P1: Unchecked pthread_create return value in test_claim_mutation_race — if any of the 5 threads fails to create, the barrier will wait forever for the missing thread (deadlock), and joining an uninitialized pthread_t is undefined behavior. Both test_mt_first_touch in this same file and every other test file (e.g. test-mprotect-mt.c, test-mmap-fastpath.c) check the return.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/test-mmap-lazy.c, line 694:
<comment>Unchecked `pthread_create` return value in `test_claim_mutation_race` — if any of the 5 threads fails to create, the barrier will wait forever for the missing thread (deadlock), and joining an uninitialized `pthread_t` is undefined behavior. Both `test_mt_first_touch` in this same file and every other test file (e.g. `test-mprotect-mt.c`, `test-mmap-fastpath.c`) check the return.</comment>
<file context>
@@ -0,0 +1,846 @@
+ int error = 0;
+ for (int i = 0; i < MT_THREADS; i++) {
+ args[i] = (claim_race_arg_t) {q, half, i, &barrier, &error};
+ pthread_create(&workers[i], NULL, claim_race_touch, &args[i]);
+ }
+ args[MT_THREADS] = (claim_race_arg_t) {q, half, 0, &barrier, &error};
</file context>
| for (int i = 0; i < MT_THREADS; i++) { | ||
| args[i] = (mt_arg_t) {p, i, &barrier}; | ||
| if (pthread_create(&th[i], NULL, mt_touch, &args[i]) != 0) { | ||
| FAIL("pthread_create"); |
There was a problem hiding this comment.
P2: Early return from test_mt_first_touch on pthread_create failure leaks already-created threads, the barrier, and the mapping. In contrast, test-mprotect-mt.c properly joins created threads, signals termination, and unmaps on the same failure path.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/test-mmap-lazy.c, line 601:
<comment>Early return from `test_mt_first_touch` on `pthread_create` failure leaks already-created threads, the barrier, and the mapping. In contrast, `test-mprotect-mt.c` properly joins created threads, signals termination, and unmaps on the same failure path.</comment>
<file context>
@@ -0,0 +1,846 @@
+ for (int i = 0; i < MT_THREADS; i++) {
+ args[i] = (mt_arg_t) {p, i, &barrier};
+ if (pthread_create(&th[i], NULL, mt_touch, &args[i]) != 0) {
+ FAIL("pthread_create");
+ return;
+ }
</file context>
| * materializing there would invert the lock order. A futex word in a | ||
| * mapping the guest never touched reads as zero, matching Linux. | ||
| */ | ||
| guest_lazy_faultin(g, uaddr, sizeof(uint32_t)); |
There was a problem hiding this comment.
P2: Pre-fault materializes lazy pages for unaligned futex addresses before the alignment check inside each command handler can return -EINVAL. The guest_lazy_faultin(g, uaddr, sizeof(uint32_t)) call at the top of sys_futex runs before any alignment validation. If a caller passes an unaligned uaddr that happens to fall on a lazy anonymous page, the pre-fault materializes that page (allocating host memory, installing PTEs) as a side effect of what should be a pure -EINVAL return path. Linux rejects the unaligned futex word without touching any page. The same risk applies to uaddr2 for FUTEX_REQUEUE, FUTEX_CMP_REQUEUE, and FUTEX_WAKE_OP. The comment block already shows awareness of side-effect concerns (unsupported commands are rejected before the pre-fault), making the gap for alignment more notable. Consider moving an alignment check before the pre-fault, or deferring the pre-fault into each case after its existing alignment guard.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/runtime/futex.c, line 1527:
<comment>Pre-fault materializes lazy pages for unaligned futex addresses before the alignment check inside each command handler can return -EINVAL. The `guest_lazy_faultin(g, uaddr, sizeof(uint32_t))` call at the top of `sys_futex` runs before any alignment validation. If a caller passes an unaligned `uaddr` that happens to fall on a lazy anonymous page, the pre-fault materializes that page (allocating host memory, installing PTEs) as a side effect of what should be a pure -EINVAL return path. Linux rejects the unaligned futex word without touching any page. The same risk applies to `uaddr2` for FUTEX_REQUEUE, FUTEX_CMP_REQUEUE, and FUTEX_WAKE_OP. The comment block already shows awareness of side-effect concerns (unsupported commands are rejected before the pre-fault), making the gap for alignment more notable. Consider moving an alignment check before the pre-fault, or deferring the pre-fault into each case after its existing alignment guard.</comment>
<file context>
@@ -1493,6 +1512,23 @@ int64_t sys_futex(guest_t *g,
+ * materializing there would invert the lock order. A futex word in a
+ * mapping the guest never touched reads as zero, matching Linux.
+ */
+ guest_lazy_faultin(g, uaddr, sizeof(uint32_t));
+ if (cmd == FUTEX_REQUEUE || cmd == FUTEX_CMP_REQUEUE ||
+ cmd == FUTEX_WAKE_OP)
</file context>
elfuse vs. OrbStack: mmap benchmark
Both runs report A. Steady-state mmap / munmap latency
B. Fresh bump-tail mmap
C. First-touch fault cost
D. mprotect split: 2 MiB block to L3, middle 4 KiB
E. mremap: 4 KiB to 8 KiB
F. Multi-threaded fresh mmap
G. munmap after materializing one 4 KiB page
H. munmap after dirtying every 4 KiB page
|
mremap_extend_range() applied guest_update_perms() over the whole 2 MiB-
aligned extension, zeroing the lazy PTEs of unrelated mappings that
shared an edge block. Snapshot per-block presence before extending, then
split and selectively invalidate only the non-kept subranges of freshly
created blocks; pre-existing blocks keep their PTEs. A single idempotent
fail label rolls back on error, restoring the page-table high-water mark
and re-invalidating the touched range.
sys_mremap()'s ENOMEM path now restores the destination snapshot page
tables so a failed grow leaves no half-mapped range.
Fold the five open-coded RX-vs-RW arena tests and four copy-pasted
high-water bumps into mmap_pt_end_for_off() and mmap_bump_next().
guest_va_block_mapped() now reports L3 table descriptors as mapped, not
only L2 block descriptors, so the presence check sees split blocks.
Add tests/bench-mmap.c, a fair mmap microbenchmark:
make build/bench-mmap
build/elfuse build/bench-mmap
Timing reads CNTVCT_EL0 directly at EL0 (enabled by CNTKCTL_EL1.EL0VCTEN),
so a sample costs an isb+mrs rather than a clock_gettime SVC that would
swamp sub-microsecond operations. The 24 MHz counter (~41.7 ns/tick) is
amortized over an adaptive batch for sub-tick effective resolution. Every
case takes one untimed warmup and reports the median (and min) over
repeated runs.
Sections:
A. mmap/munmap latency vs size, 4 KiB to 32 GiB, steady state.
B. fresh bump-tail mmap, isolating the deferred-PTE path: a bounded
run allocated without freeing so every mapping lands above the
arena high-water; every result failure-checked.
C. first-touch fault cost, one byte per page at 16 KiB stride so no
two touches share a macOS host page (every touch is a real fault).
D. mprotect split, 2 MiB block into 512 L3 pages.
E. mremap grow, in-place vs forced move.
F. multi-threaded fresh mmap under mmap_lock (2 and 4 threads); the
worker times with clock_gettime because CNTVCT_EL0 needs per-vCPU
EL0VCTEN.
A/B two builds of the same section: build elfuse with and without the
change, run the identical benchmark binary under each, and read the
delta on the affected section. Overlapping median ranges across repeated
runs mean no measurable effect; a consistent gap that scales with size
means a real one. This is how the once-present fresh-range invalidate
skip was shown to be a no-op (the invalidate is already free on an
unmapped range) and dropped.
Bootstrap sets CNTKCTL_EL1.EL0VCTEN|EL0PCTEN on the primary vCPU so EL0
can read CNTVCT_EL0, but the CLONE_THREAD and CLONE_VM worker vCPU
bring-up paths copied VBAR/MAIR/TCR/TTBR0/CPACR/SCTLR/SP/TPIDR/ELR/SPSR
and omitted CNTKCTL. Worker EL0 read 0 from CNTVCT_EL0, so the vDSO
clock_gettime trampoline's seqlock/anchor check failed and silently fell
back to the SVC clock on every non-main thread.
Set CNTKCTL_EL1 = 0x3 in both worker paths; the value is not
thread-specific.
Measured with a dynamic glibc guest (which uses the vDSO trampoline),
clock_gettime(CLOCK_MONOTONIC) averaged over 200k calls:
no fix with fix
main 8.1 ns 9.2 ns
worker 2898.5 ns 9.2 ns
Static glibc guests SVC clock_gettime even on the main thread, which is
why this stayed hidden. To reproduce: build a dynamic clock_gettime
loop, run it under elfuse --sysroot with the loop on a worker thread,
and compare against a build with the two CNTKCTL sets removed.
Add adaptive per-vCPU mmap arenas, EL1 anonymous munmap retirement with broadcast TLBI, deferred host metadata cleanup, and large backing replacement. Extend synchronization, lifecycle handling, tests, and latency benchmarks for the complete fast-path design.
Grow fully rewound mmap arenas to their adaptive target instead of repeatedly retaining undersized arenas. Isolate dirty-munmap timing from deferred cleanup and long no-exit intervals, and add regression coverage for rewind-driven arena growth.
Return metadata-committed extents to per-vCPU EL1 allocators, defer dirty backing cleanup, and avoid global PT-gate rendezvous for publication-only mmap handoffs. Add mixed-size reuse, duplicate retirement, latency, and contention coverage.
Anonymous memory is reworked on a lazy foundation: sys_mmap only records the region, while page tables, zeroing and host-memory commit are deferred to first touch. Two optimization layers sit on top.
mmap: the common shape (addr == 0, MAP_PRIVATE|MAP_ANONYMOUS, RW) is served entirely at EL1 with no vCPU exit and no lock. The shim stays stateless: it bump-allocates from a host-carved per-vCPU VA arena and reports each grant through a per-vCPU SPSC ring that the host drains whenever it takes mmap_lock, so no cross-vCPU synchronization exists. Arenas recycle freed address space through the gap allocator, refill automatically when a request does not fit (the new arena sized to cover the triggering request, with an ARENA_MAX guard so oversized requests fall back without churning the arena), and decay back to workload scale based on served-length history.
Page fault: a per-2MiB-block dirty map lets materialization skip zeroing blocks whose slab bytes are known zero, moving the host page commit off the fault and out of mmap_lock onto the guest's own first writes. The TLBI protocol emits a single range op per materialized block instead of per-page invalidations, and a repeat fault on an already-valid block returns without re-zeroing.
Host-side access to untouched lazy memory (I/O buffers, futex words, signal frames, shmat/shmdt copies) materializes on demand with lock-order-safe pre-faulting, and PROT_NONE stays a pure faulting reservation. tests/test-mmap-lazy.c and tests/test-mmap-fastpath.c pin the contract: zero-on-reuse, huge sparse reservations, host access to untouched memory, PROT_NONE round trips, fork, racing first touch, and arena refill/revocation.
Close #165
Summary by cubic
Implements lazy anonymous mmap with an EL1 fast path for common anon RW calls and EL1 deferred munmap. Cuts mmap/munmap latency, reduces contention, and fixes mremap edge cases. Closes #165.
New Features
mmap(NULL, len, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, ...)up to 32 GiB: per‑vCPU VA arenas + SPSC rings with adaptive refill and automatic fallback.munmap: retire with broadcast TLBI and defer host cleanup; drain on VM‑exit and onmmap_lockacquire; per‑2 MiB PTE‑present index accelerates teardown and arena recycle.execargv/env copying, and SysV SHM;PROT_NONEremains a pure reservation.mremap: preserve neighbor PTEs on cross‑block extend and fully roll back destination on ENOMEM; new tests cover splits, grow/move, and adjacent‑page safety.bench-mmapandbench-mmap-lazy, plus fast‑path/dirty‑map stats scripts and counters (incl. TLBI wire mode).Migration
ELFUSE_MMAP_FASTPATH=0to disable; verbose tracing, the syscall histogram, GDB, and Rosetta force the host path.Written for commit a58f5bb. Summary will update on new commits.