Skip to content

mmap/munmap EL1 fastpath - #372

Open
Max042004 wants to merge 2 commits into
sysprog21:mainfrom
Max042004:mmap-munmap-el1-fastpath
Open

mmap/munmap EL1 fastpath #372
Max042004 wants to merge 2 commits into
sysprog21:mainfrom
Max042004:mmap-munmap-el1-fastpath

Conversation

@Max042004

@Max042004 Max042004 commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

The primary goal is to reduce VM exits for mmap and munmap.

Architecture

截圖 2026-08-18 凌晨2 24 31

Anonymous mappings are lazy: sys_mmap only records the region; page-table creation, host memory commit, and zeroing of reused backing all happen at first touch.

Each vCPU owns a private contiguous virtual address arena. When the guest traps into EL1 for mmap/munmap, EL1 serves the request directly out of its own arena and never leaves EL1. The host's job is to keep those arenas supplied with VA and to reconcile their effects into host-side region/PTE state, both done lazily under mmap_lock rather than synchronously per call.

mmap fast path

EL1 serves exactly one shape:

mmap(NULL, len, PROT_READ|PROT_WRITE,
     MAP_PRIVATE|MAP_ANONYMOUS[|MAP_NORESERVE], fd, off)

file-backed mapping is excluded. The reason is that EL1 is a freestanding, no-syscall context that cannot read a file or install a page-cache overlay itself — that work is inherently the host's.

The host prepares a contiguous VA region per vCPU. Each vCPU is the sole producer of its own 32-entry publication ring and bump cursor, and publishes the extent into its ring. The host installs the corresponding PTEs.

Arena refill is dynamic, not fixed-size. Any host path that already holds mmap_lock — a syscall, a page fault, or a natural VM exit — opportunistically tops up the current thread's arena once its remaining drops under that recent high-water mark. Arena exhaustion, an unsupported shape, a full ring, or a stale arena generation each bail to the ordinary host mmap slow path.

munmap fast path

EL1 validates that the target range belongs to a live arena, clears the covering page-table entries directly, and issues a broadcast TLBI, all without leaving EL1

The address is unusable to the guest the instant EL1 returns. Only after clearing the PTEs and completing the TLBI does EL1 publish a retirement record — address, length, and the arena generation it came from — to a second, separate 32-entry SPSC ring. The host has not been involved yet at that point; its bookkeeping is reconciled lazily by mmap_fastpath_drain_locked(), which walks every vCPU's retire ring and commits the deferred cleanup. That drain runs on every natural VM exit as well as on every syscall or fault that needs the lock.

Cross-thread ordering matters here, and A and B are often touching the same address, not two unrelated ones: EL1's fast munmap scans every vCPU's arena, not just its own, because handing a fresh allocation to another thread to free is the common case. If the host were to drain B's teardown of address X before it has drained A's publication of that same X, the removal would be a no-op (X isn't in the region table yet) and the publication drain right after would insert X as live — reviving, in host bookkeeping, an address EL1 already invalidated. The drain avoids this by snapshotting every vCPU's retire-ring tail first, draining every mmap publication next, and only then consuming retirements up to that snapshot, so within one drain pass a publication a retirement depends on is always applied before the retirement removes it:

 vCPU A                                  vCPU B
 ───────                                 ───────
 mmap(len) -> X
 EL1 bump-allocates X
 ring.push(publish X)  ──┐
                         │   guest hands X to B
                         │   (shared pointer / queue)
                         └──────────────────────┐
                                                 ▼
                                        munmap(X)
                                        EL1 clears PTE(X), TLBI
                                        retire.push(X, arena=A)

 ════════════════════ host: mmap_fastpath_drain_locked() ════════════════════
  1. snapshot every retire-ring tail          <- B's entry is already in view
  2. drain ALL mmap publications              <- installs A's region X
  3. consume retirements up to that snapshot  <- removes region X

  region-table never shows X as live after EL1 has already invalidated it.

  (wrong order, for contrast: consume B's retirement first -> no region X
  exists yet, so removal is a no-op; drain A's publication after -> X gets
  inserted and marked live, even though EL1 killed it already.)

EL1 self-throttles rather than pinging the host mid-flight. If a producer's retire ring is within one slot of full, munmap bails out of the fast path entirely and falls through to the ordinary syscall trap, which drains the ring as a side effect of taking mmap_lock. Separately, once a vCPU's own unconsumed retired bytes cross a 256 MiB soft threshold, it sets an advisory cleanup_requested flag; this never forces an HVC, it only marks that real cleanup work is waiting for the next natural drain.

behavior while fork

Before the fork snapshot is taken, the parent-side handler acquires mmap_lock through the fork variant of the acquire path, drains every vCPU's publication and retire ring, and waits for any in-flight lazy materialization to finish. It then revokes every per-vCPU arena descriptor. Because sibling vCPUs are already quiesced for the fork snapshot, this revocation cannot race a live EL1 producer.

The child does not inherit the parent's arena or ring layout. The child's main thread begins with a fresh, empty arena rather than a stale one aliased to the parent's.

Any clone() call that publishes a stack for the new thread goes through this too. Any anonymous fast-path allocation that has become a live thread stack must remain reachable only through the ordinary stack-lifetime bookkeeping, not through EL1's arena-generation check, so revoking first guarantees no later EL1 fast munmap can tear down a live stack range by still recognizing it as belonging to a live arena. Only a clone() with a null child_stack skips this.

behavior while exec

sys_execve() takes mmap_lock immediately before the point of no return. That both closes the EL1 producer gate and drains every vCPU's mmap and munmap rings, so g->regions[] and PTE state agree with each other before the address space they describe is destroyed. guest_reset() then zeroes the entire shim-data page, arena, both rings.

exec does not explicitly do arena's control block comes back. The replacement image's first eligible slow-path mmap by calling the refill path and turns the fast path back.

Frama-C proof coverage

The guest-influenced arena sizing and capacity arithmetic is pulled out of src/syscall/mem.c into src/proved/mmap-fastpath.h specifically so it can be proved.

Four functions are proved, every input treated as fully guest-influenced (request_len is the guest's own mmap length; the history window is built from a sequence of guest-chosen lengths), because a slip here either wedges the allocator by undersizing an arena forever, or lets mmap_fastpath_request_fits accept a request that runs past arena_limit:

  • mmap_fastpath_request_fits: whether a len-byte request still fits before limit, covering the zero-length, sub-block, and block-aligned cases. Proved to never answer "fits" when the aligned start would actually run past limit.
  • mmap_fastpath_pow2_clamped: rounds a target size up to the nearest power of two inside [MIN, MAX].
  • mmap_fastpath_window_max: the largest of the last 16 registered mapping sizes. Proved as an upper bound over the whole history window.
  • mmap_fastpath_arena_size: the target arena size given recent history and the request about to be served. Proved to always land in [MIN, MAX], with -wp-rte separately closing both multiplication-overflow guards and the division-by-zero case.

92 of 93 discharged goals (the 93rd, align_up_ok_ensures_rejects_only_on_wrap, is a pre-existing align.h goal that also fails standalone under make verify-align in this environment, not something this change introduced).

Benchmark results

mmap-isolated-a-mmap mmap-isolated-a-munmap mmap-isolated-b-materialized mmap-isolated-c-dirty

A. One timed mmap/munmap fast-path pair per fresh process

All values are nanoseconds. Speedup is OrbStack / elfuse; values above 1
mean elfuse is faster.

Size elfuse mmap OrbStack mmap mmap speedup elfuse munmap OrbStack munmap munmap speedup
4 KiB 50.8 266.7 5.25x 47.1 268.8 5.71x
16 KiB 52.7 245.7 4.66x 51.7 251.3 4.86x
64 KiB 51.4 281.3 5.47x 51.4 294.1 5.72x
256 KiB 57.4 275.6 4.80x 55.9 313.0 5.60x
1 MiB 54.7 259.3 4.74x 54.3 359.5 6.62x
2 MiB 52.0 394.6 7.59x 54.8 267.5 4.88x
8 MiB 52.4 256.6 4.90x 52.4 282.4 5.39x
64 MiB 54.7 248.4 4.54x 56.8 335.5 5.91x
256 MiB 63.7 313.7 4.92x 57.1 574.8 10.07x
1 GiB 59.0 262.9 4.46x 52.1 834.0 16.01x
4 GiB 59.2 273.5 4.62x 53.2 904.9 17.01x
16 GiB 57.6 273.9 4.76x 52.1 856.1 16.43x
32 GiB 60.7 250.5 4.13x 54.0 973.4 18.03x

B. munmap after materializing one 4 KiB page

Fifteen fresh processes are used per size. Values are outer medians of the
per-process medians.

Size elfuse OrbStack speedup
4 KiB 357.0 608.3 1.70x
16 KiB 442.1 568.3 1.29x
64 KiB 384.9 622.7 1.62x
256 KiB 388.3 621.0 1.60x
1 MiB 430.7 730.4 1.70x
2 MiB 430.7 1429.2 3.32x
8 MiB 422.8 1245.5 2.95x
64 MiB 397.9 1366.5 3.43x
256 MiB 400.3 1591.6 3.98x
1 GiB 441.1 2481.6 5.63x
4 GiB 409.7 2436.4 5.95x
16 GiB 298.3 2376.5 7.97x
32 GiB 295.7 2290.2 7.75x

C. munmap after dirtying every 4 KiB page

One fresh process is used per size. Each process performs one warmup, then the
listed number of timed operations. The table reports the in-process
distribution.

Size elfuse p50 elfuse p95 elfuse max OrbStack p50 OrbStack p95 OrbStack max p50 speedup
4 KiB 241.3 532.9 6032.9 573.5 1031.9 6073.5 2.38x
16 KiB 324.6 616.3 10032.9 532.8 1976.5 8741.1 1.64x
64 KiB 282.9 532.9 8241.3 906.9 2142.3 17490.2 3.21x
256 KiB 282.9 574.6 8866.3 1948.4 3429.7 9448.4 6.89x
1 MiB 324.6 574.6 3491.3 5531.7 7152.6 19240.1 17.04x
2 MiB 282.9 532.9 7241.3 11052.6 15208.8 20156.7 39.07x
8 MiB 324.6 712.1 2032.9 51448.4 61521.3 65115.1 158.50x
64 MiB 657.7 1895.2 158116.0 423532.0 483825.7 676823.6 643.96x
256 MiB 1137.1 1395.4 1574.6 1812344.2 2048150.5 2105948.4 1593.83x
1 GiB 1523.0 3639.7 4106.3 7029220.3 7461557.8 7482907.8 4615.38x

munmap latency has a step right at the 2 MiB boundary. Under 2 MiB, which walks and clears one 4 KiB L3 leaf at a time.

Arena size is clamped to MMAP_FAST_ARENA_MIN = 64 MiB and MMAP_FAST_ARENA_MAX = 32 GiB per vCPU.

tests/bench-mmap-isolated all to reproduce mmap/munmap-vs-size numbers

Closes #165


Summary by cubic

Serves common anonymous mmap and munmap calls directly from EL1 without trapping to the host, and makes anonymous mappings lazy so page tables, host memory commit, and zeroing happen on first touch. Closes #165.

Behavior

  • Fast path accepts exactly mmap(NULL, len, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, ...) up to 32 GiB per request; anything else falls back to the host syscall path.
  • Each vCPU bump-allocates from a private arena and publishes mappings into a 32-entry ring the host drains under mmap_lock, so most calls never leave EL1.
  • munmap clears the covering PTEs and issues a broadcast TLBI in EL1, then defers host bookkeeping to the next drain.
  • Arena sizing adapts to recent mapping history, and retired ranges recycle back into the allocator instead of taking a refill exit.
  • A hierarchical dirty-block index lets teardown skip untouched 2 MiB blocks, so multi-GiB munmap cost is length-independent.
  • Fork revokes all arenas before snapshotting and the child starts with fresh, empty arenas; exec drains and resets them.
  • Per-thread blocked-signal masks are now written with a single atomic release, closing a race with lock-free readers on other vCPUs.

Migration

  • Fork IPC protocol magic is now ELFR (was ELFQ) because the wire payload now carries a dirty-blocks snapshot.
  • Set ELFUSE_MMAP_FASTPATH=0 to disable the fast path; verbose tracing, the syscall histogram, GDB, and Rosetta disable it automatically.
  • Guest-visible semantics are unchanged: lazy mappings read as zeros, PROT_NONE reservations still fault, and concurrent first touch from several threads is covered by new tests.

Written for commit e250001. Summary will update on new commits.

Review in cubic

Make anonymous mappings lazy: sys_mmap records the region while first
touch creates page tables, commits host memory, and zeros reused
backing. Serve common private anonymous read-write requests from
per-vCPU EL1 arenas, with publication rings that let the host reconcile
mappings under mmap_lock.

Keep shim.S focused on exception entry, register-frame preservation,
and HVC dispatch by compiling the EL1 arena consumer as freestanding C.
The mmap fast-path allocation policy then has a C home that munmap can
share.

Materialize untouched guest memory before host access and preserve
PROT_NONE reservations. Let partial guest writes materialize lazy
destinations, use tracked mremap protections for dirty state, and keep
neighboring PTEs intact when mremap grows across block boundaries.
Cover lazy reuse, refill, fork, and first-touch behavior.

Close sysprog21#165
cubic-dev-ai[bot]

This comment was marked as resolved.

@Max042004
Max042004 force-pushed the mmap-munmap-el1-fastpath branch from b8c3cd6 to 0c6ee05 Compare September 5, 2026 15:43
cubic-dev-ai[bot]

This comment was marked as resolved.

Extend the freestanding C EL1 fast path to retire compatible anonymous
mappings, invalidate their translations before return, and defer host
metadata cleanup until mmap_lock is next acquired. Return drained arena
generations to per-vCPU allocators and refill arenas from recent
registration history so mmap and munmap remain effective under reuse and
mixed mapping sizes.

Preserve the producer window around vCPU kicks and fork, and expose
counters for both munmap fallback reasons. Keep dirty backing lazy on
every anonymous munmap path;

Keep unrelated VM exits out of mmap_lock when no EL1 slot has pending
work. Lock-taking paths still drain unconditionally, and revocation
skips controls when their shim mapping is unavailable.

Prove the guest-influenced arena sizing arithmetic, isolate benchmark
samples by process, and cover retirement, reuse, refill, fallback, and
cross-vCPU publication behavior.

Store the per-thread blocked mask with one atomic release in
deliver_signal_locked, signal_deliver_fault, and signal_set_state.
signal_pending() and thread_signal_deliverable() read the field
lock-free from other vCPU threads, so the plain read-modify-write left
those reads racing against a torn store under ThreadSanitizer.
@Max042004
Max042004 force-pushed the mmap-munmap-el1-fastpath branch from 0c6ee05 to e250001 Compare September 5, 2026 17:01

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 21 files (changes from recent commits).

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-fastpath-stats.sh">

<violation number="1" location="tests/test-mmap-fastpath-stats.sh:158">
P3: The diagnostic for the altstack cases reads "only selected altstack faults in OK", which is missing a word and hard to parse. Reword it to state what the pair asserts, e.g. "only the used altstack faults in" or "altstack faults only when selected".</violation>
</file>

<file name="tests/test-mmap-fastpath.c">

<violation number="1" location="tests/test-mmap-fastpath.c:930">
P3: The prefix-hint assertion only rejects exact aliasing (q == p). If the fast path returns q anywhere inside the existing [p, p+65536) mapping, the test passes: q != p, q's pages are never touched, and both munmap(q, 4096) and munmap(p, 65536) succeed on partially-unmapped ranges. Check that q lies entirely outside p's 64 KiB extent instead.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

require_eq unused-altstack FAULT_WINDOW_BYTES 0
run_case used-altstack
require_ge used-altstack FAULT_WINDOW_BYTES 4096
printf ' only selected altstack faults in OK\n'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The diagnostic for the altstack cases reads "only selected altstack faults in OK", which is missing a word and hard to parse. Reword it to state what the pair asserts, e.g. "only the used altstack faults in" or "altstack faults only when selected".

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/test-mmap-fastpath-stats.sh, line 158:

<comment>The diagnostic for the altstack cases reads "only selected altstack faults in OK", which is missing a word and hard to parse. Reword it to state what the pair asserts, e.g. "only the used altstack faults in" or "altstack faults only when selected".</comment>

<file context>
@@ -147,6 +147,16 @@ require_eq fork-no-topup MMAP_HIT 1
+require_eq unused-altstack FAULT_WINDOW_BYTES 0
+run_case used-altstack
+require_ge used-altstack FAULT_WINDOW_BYTES 4096
+printf '  only selected altstack faults in OK\n'
+
 run_case invalid-futex
</file context>
Suggested change
printf ' only selected altstack faults in OK\n'
printf ' altstack faults only when selected OK\n'

return 1;
void *q = mmap(p, 4096, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS,
-1, 0);
if (q == MAP_FAILED || q == p)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The prefix-hint assertion only rejects exact aliasing (q == p). If the fast path returns q anywhere inside the existing [p, p+65536) mapping, the test passes: q != p, q's pages are never touched, and both munmap(q, 4096) and munmap(p, 65536) succeed on partially-unmapped ranges. Check that q lies entirely outside p's 64 KiB extent instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/test-mmap-fastpath.c, line 930:

<comment>The prefix-hint assertion only rejects exact aliasing (q == p). If the fast path returns q anywhere inside the existing [p, p+65536) mapping, the test passes: q != p, q's pages are never touched, and both munmap(q, 4096) and munmap(p, 65536) succeed on partially-unmapped ranges. Check that q lies entirely outside p's 64 KiB extent instead.</comment>

<file context>
@@ -919,6 +919,57 @@ static int stats_fork_no_topup(void)
+        return 1;
+    void *q = mmap(p, 4096, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS,
+                   -1, 0);
+    if (q == MAP_FAILED || q == p)
+        return 1;
+    void *r = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
</file context>
Suggested change
if (q == MAP_FAILED || q == p)
uintptr_t qa = (uintptr_t) q, pa = (uintptr_t) p;
if (q == MAP_FAILED || (qa >= pa && qa < pa + 65536))
return 1;

Comment thread src/core/guest.c

int rc;
mmap_lock_acquire((guest_t *) (uintptr_t) cg);
rc = guest_lazy_faultin_locked(cg, gva, len);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gva_resolve_perm now calls this on any walk miss, so every guest_read, guest_write, and guest_ptr can acquire mmap_lock. mmap_lock is a default (non-recursive) pthread mutex, and fuse_dev_read holds session->lock across guest_write(g, buf_gva, req->frame, frame_len) while sys_mmap can hold mmap_lock across fuse_materialize_fd, so a FUSE daemon reading a request into an untouched anonymous buffer deadlocks the VM: the requester holds mmap_lock waiting for the daemon, the daemon needs mmap_lock to deliver the frame.

The per-call-site pre-faulting added for futex, mincore and sysvipc does not scale to every caller of a resolve API this widely used. Consider having gva_lazy_faultin return -1 when the calling thread already holds mmap_lock (mmap_lock_guest already records that), so a missed pre-fault surfaces as EFAULT instead of a hang.

Comment thread src/syscall/mem.c
}
}

guest_materialize_wait_range_locked(g, start, end);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

guest_materialize_wait_range_locked does not exclude the caller's own claim, and this drain is reachable from mmap_lock_reacquire_with_gate while guest_materialize_lazy_one holds a claim over the block it is zeroing. It is safe today only because the retire rings are always empty at that point (the gate stays closed across mmap_lock_drop_keep_gate, so no producer can push), which is an invariant nothing states or checks.

Worth either asserting the rings are empty here or skipping the caller's own claim slot in the wait.

Comment thread src/syscall/mem.c
* unrelated VM exit (often the next mapping's first fault) with an eager
* memset of the retired range. guest_materialize_lazy_one() zeros dirty
* backing before publishing any new descriptor, so a future reader can
* never observe stale bytes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This holds for the fault path but not for mremap. copy_mremap_source's non-overlay branch memmoves raw slab bytes for the source range without materializing it first, and mremap_extend_range then installs valid PTEs over the copy.

Touch a large anonymous mapping, munmap it, mmap the same size again so the allocator reuses that backing, then mremap the fresh mapping without touching it: the destination reads the earlier mapping's bytes rather than zero. sys_mremap needs guest_lazy_faultin_locked over the source range, or an explicit zero of its never-materialized blocks, before any raw copy.

Comment thread src/syscall/mem.c
* retirement entries have committed.
*/
if (retired_any)
guest_pt_gen_bump(g);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

guest_pt_gen_bump is the only thing that invalidates the per-thread gva_tlb entry in gva_translate_perm, and EL1 clears the descriptors and returns to the guest long before this drain runs. The broadcast TLBI reaches the architectural TLBs only.

A host thread that is not a vCPU thread, so the vm-exit drain in vcpu_run_loop is not on its path, can hold a gva_tlb entry populated before the fast munmap and keep translating the retired range as valid until some other thread happens to take mmap_lock. An epoch that EL1 bumps in shim_data after its descriptor stores, folded into the gva_tlb validity test, would close that window without a host round trip.

Comment thread src/syscall/sysvipc.c
* self-deadlock hazard as the shmat copy-in: pages the guest never
* touched may still be unmaterialized.
*/
guest_lazy_faultin_locked(g, entry.guest_gva, entry.size);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pre-fault is best effort, but guest_read below still faults on a miss, so this can re-enter mmap_lock, which sc_shmdt already holds. shmat, then munmap(shmaddr, size), then shmdt(shmaddr) leaves nothing for guest_lazy_faultin_locked to materialize, the first gva_resolve_perm returns NULL, and gva_lazy_faultin blocks on the non-recursive mmap_lock, hanging every vCPU.

sc_mincore has the right shape at src/syscall/syscall.c:761: pre-fault, then copy with a nofault variant. The guest_write at line 264 is the same shape and can use the existing guest_write_nofault; this one needs a guest_read_nofault added.

@jserv

jserv commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Lazy anonymous mappings can hand the guest bytes from a previously freed mapping: mremap's relocating path memmoves raw slab bytes from a source whose blocks were never materialized, so the zeroing that munmap defers never runs for them. The detail is on the inline comment at src/syscall/mem.c:330. Separately, the new fault-in inside guest_read/guest_write can re-enter the non-recursive mmap_lock, which hangs the VM on two paths that already hold it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sys_mmap: eager zero-fill on MAP_ANONYMOUS makes sparse mappings ~5x slower than Linux

2 participants