Skip to content

Optimize anonymous mmap and page-fault paths#203

Draft
Max042004 wants to merge 9 commits into
sysprog21:mainfrom
Max042004:elfuse-mmap
Draft

Optimize anonymous mmap and page-fault paths#203
Max042004 wants to merge 9 commits into
sysprog21:mainfrom
Max042004:elfuse-mmap

Conversation

@Max042004

@Max042004 Max042004 commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

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

    • Lazy anon memory with a per‑2 MiB dirty map: skip zeroing clean blocks, batch TLBI per block, ignore repeat faults; bootstrap marks ELF/shim/stack blocks dirty to preserve zero‑on‑reuse on MAP_FIXED.
    • EL1 mmap fast path for 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.
    • EL1 deferred munmap: retire with broadcast TLBI and defer host cleanup; drain on VM‑exit and on mmap_lock acquire; per‑2 MiB PTE‑present index accelerates teardown and arena recycle.
    • Host access pre‑faulting for futex words, signal frames, exec argv/env copying, and SysV SHM; PROT_NONE remains a pure reservation.
    • Robust 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.
    • Timing and tooling: enable CNTVCT reads on worker vCPUs; add bench-mmap and bench-mmap-lazy, plus fast‑path/dirty‑map stats scripts and counters (incl. TLBI wire mode).
  • Migration

    • EL1 mmap/munmap fast paths are on by default and fall back when shape unsupported, arenas exhausted, or rings full. Set ELFUSE_MMAP_FASTPATH=0 to disable; verbose tracing, the syscall histogram, GDB, and Rosetta force the host path.
    • Fork IPC protocol bumped to ELFM. Update both parent and child when mixing versions.

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

Review in cubic

cubic-dev-ai[bot]

This comment was marked as resolved.

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
@jserv

jserv commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Provide comprehensive benchmarks.

Comment thread src/core/shim.S Outdated
Comment thread src/syscall/mem.c
(unsigned long long) arena_base,
(unsigned long long) arena_limit);
}
if (guest_region_add_ex(g, addr, addr + 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.

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.

@Max042004

This comment was marked as outdated.

@jserv

jserv commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

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)

mremap_extend_range() applied guest_update_perms() over the whole 2 MiB-aligned extension, which zeroed the lazy PTEs of unrelated mappings that shared an edge block. It now snapshots per-block presence before extending, then splits and selectively invalidates only the non-kept subranges of freshly created blocks; pre-existing blocks keep their PTEs. A single idempotent rollback path restores the page-table high-water mark on failure, the ENOMEM sys_mremap path restores the destination snapshot page tables, and the 5 open-coded RX/RW arena tests + 4 copy-pasted high-water bumps collapse into mmap_pt_end_for_off() / mmap_bump_next(). guest_va_block_mapped() now also reports L3 table descriptors as mapped. New tests: adjacent-page-fault, neighbor-L3 preservation, hinted-tail zero.

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 CNTKCTL_EL1.EL0VCTEN|EL0PCTEN on the primary vCPU, but the CLONE_THREAD and CLONE_VM worker bring-up paths omitted it. 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. Dynamic glibc, 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

~315x on worker threads. Static glibc guests SVC clock_gettime even on the main thread, which is why this stayed hidden.

Benchmark (tests/bench-mmap.c, Apple M1 / MacBookAir10,1)

Timing reads CNTVCT_EL0 directly at EL0 (isb; mrs), not a clock_gettime SVC that would swamp sub-microsecond ops; the 24 MHz counter (~41.7 ns/tick) is amortized over an adaptive batch. One untimed warmup per case, median (and min) over runs.

== A. mmap / munmap latency vs size (steady state, NULL hint) ==
size        batch      mmap ns    munmap ns
4 KiB          64        212.9       2414.1
16 KiB         64        212.9       2397.1
64 KiB         64        213.5       2403.6
256 KiB        64        212.9       2399.1
1 MiB          64        263.0       2399.1
2 MiB          64        278.6       2398.4
8 MiB          32        290.4       2432.3
64 MiB          4         72.9       2666.7
256 MiB         1         83.3       3416.7
1 GiB           1         83.3       2500.0
4 GiB           1       3666.7       6291.7
16 GiB          1       3666.7       6375.0
32 GiB          1       3708.3       6458.3

== B. fresh bump-tail mmap, per-mmap ns ==
size          count  fresh mmap ns
4 KiB          1000          259.5
2 MiB          1000          283.0
128 MiB          16          286.5
32 GiB            1         2666.7

== C. first-touch fault (16 KiB stride, 512 pages) ==
  per-fault: median 5058.0 ns  min 4855.1 ns

== D. mprotect split (2 MiB block -> L3, protect middle 4 KiB) ==
  split: median 4083.3 ns  min 3500.0 ns

== E. mremap grow 4 KiB -> 8 KiB ==
  in-place: median 3250.0 ns  min 3041.7 ns
  move:     median 4291.7 ns  min 3750.0 ns

== F. multi-threaded fresh mmap, per-op ns (mmap_lock contention) ==
size        threads      mean ns       max ns
4 KiB             2       1403.0       1623.3
4 KiB             4       2641.0       3541.3
2 MiB             2        861.0        939.3
2 MiB             4       1709.0       1937.3

Notes on reading this: mmap latency is flat with size on the lazy path (~210 ns to 32 GiB); the real allocation-path cost is munmap (~2400 ns, ~10x mmap). To A/B a page-table change, build elfuse with and without it and run the identical benchmark binary under each: overlapping median ranges across repeated runs mean no measurable effect, a consistent gap that scales with size means a real one. Section F times with clock_gettime because CNTVCT_EL0 needs per-vCPU EL0VCTEN (see change 2).

Build/run:

make build/bench-mmap
build/elfuse build/bench-mmap

@Max042004 , please measure on Apple M4 and analyze carefully.

@Max042004

Copy link
Copy Markdown
Collaborator Author

Deferred EL1 munmap: Flow, Synchronization, and Long Tails

The goal of this design is not to eliminate all munmap work, but to divide it
into two stages with different correctness deadlines:

  1. Guest-visible retirement must complete before munmap() returns. EL1
    clears the Stage-1 descriptor, broadcasts a TLB invalidation to all vCPUs,
    and only then publishes the retire record.
  2. Host bookkeeping and backing reclamation do not affect the address-space
    semantics immediately visible to the guest, so they can be batched at the
    next natural VM exit.

As a result, the common anonymous munmap() path requires no HVC, worker
thread, or synchronous wait for macOS memory cleanup.

The Complete Flow at a Glance

flowchart TD
    U[EL0: munmap addr, len] --> E{EL1 fast path available?}
    E -- No --> S[HVC slow path]
    E -- Yes --> R[Reserve this vCPU's retire-ring slot<br/>Fill data without publishing tail]
    R --> P[Validate and clear Stage-1 PTE / L2 block]
    P --> T[DSB ISHST<br/>broadcast TLBI<br/>DSB ISH + ISB]
    T --> Q[release-store tail<br/>Publish retire record]
    Q --> Z[Return directly to EL0]

    Z -. Any later natural VM exit .-> H[Host acquire-snapshots<br/>all retire tails]
    H --> M[Drain mmap publications first]
    M --> C[Commit retire metadata]
    C --> B{Complete, exclusive dirty run<br/>and materialized >= 32 MiB?}
    B -- Yes --> X[Quiesce sibling vCPUs<br/>HVF detach + fresh backing + remap]
    B -- No --> Y[memset dirty fragments]
    X --> A[Remove region metadata<br/>Release quarantine]
    Y --> A
Loading

The solid path in the diagram is the work that must complete during this
munmap(). Host cleanup below the dotted edge occurs at a later VM exit. The
cleanup has not disappeared; it is simply no longer included in the
guest-visible munmap latency.

Stage One: EL1 Immediately Completes the Guest-Visible Unmap

Fast-Path Eligibility

EL1 accepts only requests with a simple enough shape to complete safely
without allocating host metadata:

  • addr and length are both 4 KiB aligned, length != 0, and
    addr + length does not overflow.
  • The entire range belongs to one still-valid anonymous mmap fast-arena
    generation.
  • The range is not a stack, file overlay, MAP_SHARED mapping, or elfuse
    infrastructure.
  • If a partial 2 MiB block must be cleared page by page, its L3 table already
    exists. EL1 does not split blocks or allocate new page-table pages.
  • This vCPU's retire ring is not near-full.
  • The host page-table gate remains open.

If any condition fails, execution returns to the existing SVC -> HVC slow
path. A fallback is a normal correctness path, not an error.

flowchart TD
    A[Receive munmap] --> V{Aligned, nonzero, no overflow?}
    V -- No --> H[HVC slow path]
    V -- Yes --> F{Retire-ring occupancy < 31?}
    F -- No --> H
    F -- Yes --> G{Join PT-gate handshake?}
    G -- No --> H
    G -- Yes --> N{One valid arena generation<br/>contains the whole range?}
    N -- No --> H
    N -- Yes --> L{Does every partial L2 already have L3?}
    L -- No --> H
    L -- Yes --> R[Reserve slot]
    R --> W[Walk / clear descriptors]
    W --> T[TLBI + completion barriers]
    T --> P[Publish tail]
    P --> O[Return 0]
Loading

Why the Ring Slot Must Be Reserved First

Each vCPU owns a 32-entry SPSC ring:

struct munmap_retire_entry {
    uint64_t addr;
    uint64_t length;
    uint32_t arena_generation;
    uint32_t flags; /* arena slot + charged materialized pages */
};

EL1 is the ring's only producer, and the host is its only consumer. The
producer first checks head and tail and fills the slot, but does not advance
tail. This prevents the unrecoverable state in which a PTE has already been
cleared before EL1 discovers that no space remains to record the metadata
retirement.

A per-vCPU SPSC design has two additional benefits: producers never write the
same tail or cause cache-line bouncing on a shared MPSC bitmap, and host
allocator state remains exclusively host-owned.

The Two Page-Table Walk Paths

EL1 validates first and mutates second. The generic path checks L0, L1, and L2,
clears one descriptor for a complete L2 block, and clears the corresponding
4 KiB leaves in an existing L3 table. The two-pass walk ensures that mutation
cannot begin before discovering that a 2 MiB block would need to be split.

The common large, 2 MiB-aligned case uses the L2-only path:

  • L0 is walked only once.
  • One L1 entry is looked up per 1 GiB window.
  • Consecutive L2 descriptors are validated and cleared linearly.
  • After validation, EL1 uses ordinary aligned 64-bit stores. The host gate has
    already excluded host writers, and a sibling fast producer can only write
    zero to the same descriptor.
  • All descriptor stores share one final DSB ISHST.

This means a 1 GiB retirement does not need to visit 262,144 individual 4 KiB
PTEs. If the range consists of 2 MiB blocks, EL1 processes only 512 L2
descriptors.

Translation Ordering Required Before Return

The critical order is:

clear Stage-1 descriptors
        ↓
DSB ISHST
        ↓
TLBI ...IS                 (inner-shareable broadcast)
        ↓
DSB ISH
ISB
        ↓
release-store retire.tail
        ↓
return to EL0

Small ranges use per-page VAE1IS. Larger ranges use RVAE1IS when the CPU
supports FEAT_TLBIRANGE, directly encoding SCALE 0 through 3. One SCALE=3
instruction can cover up to 8 GiB. Ranges larger than one instruction's
coverage are emitted as adjacent batches that share the final completion
barrier. Without range-TLBI support, the path falls back to VMALLE1IS.

The release-store to tail occurs after TLBI completion. Once the host sees
the record with an acquire-load, it therefore also knows that:

  • all entry fields are fully visible;
  • the descriptor stores have completed;
  • stale translations on sibling vCPUs have been invalidated; and
  • any subsequent EL0 access to the range must fault.

These are the Linux address-space semantics that must be guaranteed when
munmap() returns.

Stage Two: Host State Is Reconciled at the Next Natural VM Exit

The host drains after every return from hv_vcpu_run() and before
dispatching the exit reason. Syscalls, page faults, signals, MAP_FIXED,
fork/exec, and process exit therefore cannot inspect stale region metadata
first.

This ordering is especially important for page faults. If the fault handler
consulted old metadata first, it could rematerialize the mapping that EL1 had
just cleared, invalidating the semantics of an already successful munmap().

The host commit flow is:

  1. Acquire-snapshot the retire tails of all vCPUs.
  2. Drain every mmap publication ring.
  3. Validate each retire entry's range, arena slot, and generation.
  4. Wait for overlapping lazy materialization to finish.
  5. Zero or replace the backing.
  6. Remove guest_region metadata and update dirty state, gap hints, and the
    page-table cache generation.
  7. Release-store the ring head, allowing the producer to reuse the slot.
  8. Release the VA quarantine only after the range has been fully committed.

The host does not clear PTEs or execute TLBI again, because EL1 has already
completed both operations.

How Large Zeroing Is Delegated to macOS

"Delegating zeroing to macOS" more precisely means discarding the old backing
and allowing macOS to provide clean pages later through zero-fill-on-demand.
It does not mean synchronously asking macOS to write zero to every byte.

A dirty run uses backing replacement only if all of the following conditions
hold:

  • EL1 counted at least 32 MiB of actually materialized backing. A large virtual
    range with only one touched page does not qualify.
  • The run consists of contiguous, complete, 2 MiB-aligned dirty blocks.
  • No live alias remains for the backing; the retirement owns the entire run
    exclusively.

Replacement first quiesces sibling vCPUs, splits only the two HVF segment
boundaries of the range, and then performs:

hv_vm_unmap(target IPA)
        ↓
shared slab: F_PUNCHHOLE + MAP_SHARED | MAP_FIXED
private slab: fresh MAP_ANON | MAP_PRIVATE | MAP_FIXED
        ↓
hv_vm_map(same IPA, fresh host backing)
        ↓
resume sibling vCPUs

Here, hv_vm_unmap() temporarily detaches the Stage-2/HVF mapping; it does not
execute guest munmap a second time. If the range is too short, unaligned,
aliased, impossible to split safely at its segment boundaries, or any
replacement step fails, the host conservatively falls back to memset for the
dirty fragments. The 32 MiB threshold is a crossover selected from
measurements on Apple M4, not a correctness constant.

VA Quarantine: TLBI Completion Does Not Make the Address Reusable

EL1 has made the old VA inaccessible, but host metadata has not yet committed.
If the mmap fast path issued the same address now, an old retire record could
remove the new mapping as well. The address must therefore pass through an
explicit state transition:

stateDiagram-v2
    [*] --> Mapped
    Mapped --> Retired: EL1 clears PTE + TLBI
    Retired --> Retired: host has not drained<br/>VA quarantined
    Retired --> Reusable: metadata/backing commit
    Reusable --> Mapped: mmap allocation
Loading

While in the Retired state:

  • the mmap arena allocator must skip the range;
  • arena rewind may advance only past committed retire entries;
  • host paths such as MAP_FIXED, mprotect, and mremap that may inspect
    metadata or reuse an address must drain first; and
  • process teardown must perform a final drain.

If the guest runs for a long time without any natural VM exit, metadata may
remain temporarily without violating correctness. When ring occupancy reaches
31, the next munmap falls back to HVC and drains the batch. A separate
256 MiB materialized pending-byte advisory sets cleanup_requested when
crossed, but does not force the current vCPU to exit. Ring near-full remains
the only hard backpressure from pending retirement volume.

Synchronization

Host Writers and EL1 Producers: The PT Gate

Per-vCPU rings remove contention among producers, but the host may still run
mmap, mprotect, page-table extension, or backing replacement concurrently.
These host writers and EL1 PTE writers coordinate through one shared gate and
one private producer_active word per vCPU:

sequenceDiagram
    participant E as EL1 producer
    participant G as shared PT gate
    participant H as host writer

    E->>G: acquire-load gate == 0
    E->>E: release-store producer_active = 1
    E->>G: acquire-load gate again
    alt gate is still 0
        E->>E: validate / clear PTE / TLBI / publish
        E->>E: release-store producer_active = 0
    else host has closed the gate
        E->>E: producer_active = 0, fallback to HVC
    end

    H->>G: acq_rel increment (close)
    H->>E: acquire-wait producer_active == 0
    H->>H: drain / modify page tables
    H->>G: release decrement (open)
Loading

The second gate read closes the race between checking the gate and announcing
an active producer. The host closes the gate before waiting for every active
producer to leave, thereby acquiring page-table writer exclusion. Producers
do not lock one another because their writes to overlapping descriptors are
idempotent zero stores.

vCPU A Performs mmap, vCPU B Performs munmap: Cross-Ring Causality

Suppose A fast-mmaps a region and transfers the pointer to B through a
release/acquire handoff, after which B immediately fast-munmaps it. A's mmap
publication and B's retirement reside in different SPSC rings, so the host
cannot drain them in an arbitrary order.

sequenceDiagram
    participant A as vCPU A
    participant B as vCPU B
    participant H as host

    A->>A: publish mmap entry
    A->>B: release/acquire handoff pointer
    B->>B: clear PTE + TLBI
    B->>B: release-publish retire tail
    H->>B: acquire-snapshot retire tail
    H->>A: drain all mmap publications
    H->>H: region metadata now exists
    H->>B: commit snapshotted retirement
Loading

The host must perform these operations in order:

  1. Acquire-snapshot all retire tails.
  2. Drain all mmap publications.
  3. Process only the retire entries included in the snapshots.

The acquire of B's tail establishes transitive causal ordering. The host then
creates A's semantic region before removing it. arena_generation prevents a
delayed record from damaging a newly provisioned arena.

Latency and Long Tails

What munmap Timing Actually Includes

flowchart LR
    subgraph Timed[guest-visible munmap latency]
        V[validate] --> D[descriptor scan/clear]
        D --> T[TLBI issue + completion]
        T --> P[ring publish]
    end
    P -. later VM exit .-> C[metadata commit]
    C --> R[backing discard / memset]
Loading

Fast-path latency still includes two size-dependent costs: descriptor
scan/clear and TLBI completion. It does not include the later region removal,
memset, sibling quiescence, or HVF unmap/remap.

Moving this work out of munmap does not make it free. Deferred cleanup adds
service time to the next natural VM exit. If the retire ring becomes near-full,
the munmap that triggers the fallback also observes the long tail of a batch
drain.

Why TLBI Has a Long Tail

DSB ISH must wait for the inner-shareable invalidation to complete; TLBI is
not merely a fire-and-forget instruction. Its latency is affected by:

  • whether sibling vCPUs hold translations for the range;
  • the SCALE/NUM encoding and instruction batch count of the range TLBI;
  • current activity on the coherency fabric and other cores; and
  • macOS preempting the vCPU host thread between the two CNTVCT reads.

The first two items are translation-correctness costs that the architecture
requires EL1 to wait for. The final item is wall-clock measurement noise, but
it is still a real long tail observed by the application. Latency therefore
need not increase monotonically with size. For example, 256 MiB may lie at a
SCALE=2 boundary while a larger envelope switches to SCALE=3.

The Correct Microbenchmark Method

The old H benchmark used adaptive batches: a sample ended when accumulated
counter ticks reached a threshold or accumulated dirty bytes reached a cap.
This allowed the first slow operation to end the sample immediately. For
example, one 1.1 ms long tail at 256 MiB became the entire sample, while the few
1 GiB operations happened not to be preempted and appeared to take only about
2.4 us. This was not a sound comparison of steady-state costs.

The corrected benchmark uses a fixed operation count for each size, records
every latency, and reports p50, the R-7 interpolated p95, and max. One
representative run on Apple M4 produced:

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:

  1. No PTE may be modified without a reserved retire slot.
  2. The retire tail may not be published, and EL0 may not resume, before the
    broadcast TLBI has completed.
  3. Before the host can observe a retire record, both the entry and translation
    retirement must have completed.
  4. Every metadata-reading host path must drain first.
  5. Mmap publications must commit before snapshotted retirements.
  6. A retired VA may not be reallocated before host commit.
  7. Before fresh backing replacement, the target must have no live PTE or
    external alias, and sibling vCPUs must be quiesced.
  8. 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,
    scaled RVAE1IS, 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.

@Max042004

This comment was marked as outdated.

@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.

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

Comment thread src/syscall/mem.c Outdated
Comment thread tests/test-mmap-lazy.c
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]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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>

Comment thread src/core/bootstrap.c
Comment thread tests/test-mmap-lazy.c
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");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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>

Comment thread src/runtime/futex.c
* 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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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>

Comment thread src/core/guest.c
@Max042004

Copy link
Copy Markdown
Collaborator Author

elfuse vs. OrbStack: mmap benchmark

elfuse: build/elfuse build/bench-mmap
OrbStack: orb ./build/bench-mmap

Both runs report CNTVCT 41.67 ns/tick. Timings are in ns unless marked us.
The tables intentionally omit iteration counts to keep the elfuse-vs.-OrbStack
comparison immediately visible.

A. Steady-state mmap / munmap latency

Size mmap: elfuse mmap: OrbStack munmap: elfuse munmap: OrbStack
4 KiB 49.8 177.7 211.6 283.6
16 KiB 37.8 176.0 169.7 279.9
64 KiB 30.2 176.6 143.6 280.0
256 KiB 24.4 176.2 126.2 297.8
1 MiB 21.6 176.1 115.9 370.4
2 MiB 20.9 179.5 112.4 274.7
8 MiB 18.2 176.2 103.2 262.9
64 MiB 18.0 175.5 102.4 313.3
256 MiB 17.8 177.6 101.8 401.2
1 GiB 17.9 178.9 102.6 403.8
4 GiB 197.8 174.8 16.8 390.9
16 GiB 577.7 192.9 18.2 421.9
32 GiB 1083.2 181.3 19.5 477.1

B. Fresh bump-tail mmap

Size elfuse OrbStack
4 KiB 90.0 180.0
64 KiB 89.7 173.9
1 MiB 89.8 188.5
2 MiB 89.8 180.2
8 MiB 80.2 211.8
128 MiB 39.1 244.8
1 GiB 36.5 244.8
8 GiB 583.3 239.6
32 GiB 1875.0 854.2

C. First-touch fault cost

Metric elfuse OrbStack
Sweep p50 79.4 us 240.0 us
Sweep p95 734.3 us 457.7 us
Sweep max 760.6 us 477.1 us
Amortized/touch p50 155.0 468.8
Amortized/touch p95 1434.1 893.9
Amortized/touch max 1485.5 931.9

D. mprotect split: 2 MiB block to L3, middle 4 KiB

Operation elfuse median OrbStack median elfuse min OrbStack min
Split 791.7 1708.3 291.7 958.3
Fast leaf 375.0 500.0 291.7 458.3

E. mremap: 4 KiB to 8 KiB

Operation elfuse median OrbStack median elfuse min OrbStack min
In-place grow 1625.0 375.0 1500.0 333.3
Move 2041.7 375.0 1500.0 333.3

F. Multi-threaded fresh mmap

Size Threads elfuse mean OrbStack mean elfuse max OrbStack max
4 KiB 2 206.3 591.1 229.3 615.8
4 KiB 4 736.3 386.7 898.7 591.7
2 MiB 2 308.7 627.3 329.3 636.9
2 MiB 4 901.3 1029.0 1065.3 1180.2

G. munmap after materializing one 4 KiB page

Size elfuse OrbStack
4 KiB 310.0 422.9
16 KiB 360.1 391.8
64 KiB 246.4 403.6
256 KiB 293.6 433.5
1 MiB 479.6 786.8
2 MiB 615.0 962.6
8 MiB 626.4 902.8
64 MiB 285.6 1037.2
256 MiB 289.8 1218.0
1 GiB 328.1 1833.1
4 GiB 297.1 2329.7
16 GiB 289.5 1847.2
32 GiB 287.8 2531.0

H. munmap after dirtying every 4 KiB page

Size p50: elfuse p50: OrbStack p95: elfuse p95: OrbStack max: elfuse max: OrbStack
4 KiB 199.5 490.8 449.5 824.2 9574.5 6240.8
16 KiB 241.1 449.2 616.1 740.8 8699.5 15324.2
64 KiB 282.8 740.8 699.5 907.5 13491.1 11574.2
256 KiB 282.8 1657.5 407.8 2199.2 2366.1 11282.5
1 MiB 491.1 5449.2 843.2 6426.2 11616.1 32115.8
2 MiB 532.8 9740.8 616.1 10730.4 12032.8 22365.8
8 MiB 574.5 44095.0 1003.6 47961.7 2241.1 58865.8
64 MiB 824.5 368074.2 2449.5 401917.9 3449.5 406657.5
256 MiB 1928.6 1427011.7 3730.7 1467692.9 4699.5 1643324.2
1 GiB 2866.1 5997178.3 5132.8 6121607.5 5657.8 6146865.8

@Max042004
Max042004 marked this pull request as draft July 17, 2026 16:08
jserv and others added 8 commits July 18, 2026 00:13
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.
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