Skip to content

A draw of many promised frames takes the allocator's lock once - #1268

Draft
Japabu wants to merge 2 commits into
spawnperffrom
pmm-batch
Draft

Japabu wants to merge 2 commits into
spawnperffrom
pmm-batch

Conversation

@Japabu

@Japabu Japabu commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #1250 (base spawnperf, head e343eb5, on main 29c5f2d): this PR's diff is two commits, the batch
29585b5 and the records commit 3eef788 (the batch built, and what it owes on metal). It is retargeted to main
once #1250 lands.

Problem

Every frame a shared-memory region or a fault's populated run drew took the frame allocator's one lock on its own:
512 holds for a 2 MiB region, up to 16 for a fault. The spawn diagnosis (#1265, boot 3) measured what that costs the
all-CPU build: with one build child a CPU the lock is 99 % of every contended lock wait, its waits 1.05 of the T14's 8
CPUs and 7.1 of the AMD bench's 16 for the whole round, 82 % of the spin in the children's SHM_CREATEs (by inference
from the SDK, their 2 MiB directory windows) and 13 to 14 % in their faults. Lock spin is a third of the children's
growth in CPU on the T14 and 72 % on the AMD bench, so the gain expected on the T14 is the smaller.

Behavior

No loom or concurrency model: every draw and free still happens under the one allocator lock, as before, so a batch
meets concurrent frees exactly as consecutive single draws did. The lock-free part, the pin counts, decides only which
freed frames return to the free map (Pins::defer, release), which a draw never reads.

Checks

Metal (owed, not run)

The benches are down, so this lands on review, the host checks and the guest rows, and the speed-up is not yet
measured on metal
. Owed on metal, recorded in the frame allocator's record:

  • an A/B of main before and after this on the T14 and the AMD bench, of osbench work (work.build_shaped.j1, and
    .j8 on the T14 and .j16 on the AMD bench), spawn_cost and osbench procs: main after must show
    work.build_shaped.j<cpus> faster on both benches and no j1 or proc row slower beyond the before run's spread;
  • the build-shaped set's lock census (the diagnosis's boot 3 instrument, spawnperf-split, carried onto main after):
    the allocator lock's share of contended waits, 99 % before, fallen on both.

The A/B that was staged (request5.txt in the evidence) stays as the record; its images are deleted.

Risks

  • A region's creation now holds the allocator's lock for up to 512 draws at a time instead of one; what one hold
    costs is not measured. A region of 2 MiB or less takes one hold.

🤖 Generated with Claude Code

@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #1268 at 2cfa788 (base spawnperf = #1250 at 1d151cb)

Architecture

The owner and the shape are right.

  • One primitive in the allocator. Frames::alloc_promised_each draws single promised frames in a loop under the caller's one hold, so the rules for promises and ledgers stay in one place.
  • pmm bounds each hold. The limit is a superframe's worth (DRAWS_A_HOLD). That is a property of the lock, so it belongs in pmm and not at each caller.
  • Two callers, one each. A region takes zeroed Frames. A fault's run takes unzeroed raw frames, and fill_new writes each one as its page starts. This keeps A spawn reserves its stack once, and a committed page's frame is written once #1250's single write per frame.
  • Nothing else changes. There is no new state and no new lock, and frees are unchanged.

Holding per-CPU caches until this is measured is the right order.

Line counts: production about +48 (frames +11, pmm +31, space +10, shm −4); tests +23.

The questions asked

  • Concurrent frees and promises. Each batch is atomic under FRAMES.
    • Between the holds of a region larger than 2 MiB, other CPUs draw only against their own promises.
    • The region's undrawn frames stay counted in promised until they are drawn.
    • admit keeps what is promised within the free frames, and A spawn reserves its stack once, and a committed page's frame is written once #1250's discard assert keeps every repromise backed by a free frame.
    • So every later hold finds its frames, just as consecutive single draws did. No model is needed, because nothing lock-free changed.
  • Every frame is written before it is mapped.
    • Fault path: fill_new runs inside fill's closure, before the leaf is stored (LeafTable::fill on both architectures).
    • Region path: alloc_promised_many zeroes every frame before it returns.
    • The run's count matches what fill fills. The empty count (space.rs:794) and fill's visits (:799) cover the same empty leaves of [lo, hi) under &mut self, and nothing between them writes a leaf. So the expect cannot fire, and no drawn frame is left unmapped.
  • Error paths: none is partial.
    • Every page of the run is empty and belongs to one committed entry, so each holds a promise. The draw can panic only on a kernel accounting bug.
    • fill_new cannot fail except at read's bound, which is a kernel bug that A spawn reserves its stack once, and a committed page's frame is written once #1250's B1 closed.
    • A region calls pmm::promise(job, pages) before drawing anything and returns on refusal before any draw.
    • No Err can orphan a drawn frame.
  • The lock hold is bounded.
    • A hold makes at most 512 take_frame calls. Each scans at most 8 words, with at most about one superframe carve per 512 draws.
    • Nothing allocates under the lock: the region's Vec capacity is reserved beforehand, and a run's slots are on the stack.
    • Zeroing and the pages' writes happen after the lock is released.
    • The hold's duration is not measured; a few µs is my inference. The PR's Risks section says so, and the A/B reads throughput rather than hold time.

Findings

BLOCKER 1 (inherited from #1250). kernel/src/mm/pmm.rs:138: free_drawn's Safety section requires "a frame [Frame::into_raw] gave up". alloc_promised_into's doc (:333-334) now sends raw frames to it as well. #1250's BLOCKER 1 fixes it, and the rebase brings the fix here. Make alloc_promised_into's "gives them back through [free_drawn]" agree with the fixed text; a page's frame also goes back through discard.

BLOCKER 2. kernel/pure/frames.rs:773-776 and :1369-1375: the test of the up-front check shows nothing beyond its message.

  • alloc_promised already panics on a draw when nothing is promised (frames.rs:765).
  • The evidence's control confirms this. Without the check, a_batch_past_its_promises_is_a_panic still panics, at the eleventh single draw, and fails only because the message differs: "frames: a promised frame drawn and none was promised" against the expected "11 promised frames drawn of 10 promised".
  • "Before any is taken" has no consequence: a kernel panic does not unwind, and no caller recovers from it.
  • So the test can expose no defect, and root's testing policy says not to test what inspection establishes.
  • Fix: delete the test and the doc's "before any is taken" sentence. The assert may stay for its clearer message, or go.
  • The PR body's "Negative control" bullet goes with it; correcting the body is the orchestrator's job.

SUGGESTION. When this lands, the first decision in #1265's allocator record is built. Whichever of the two lands second should update that record to say so, so that only its measurement stays owed. They merge cleanly (git merge-tree exits 0).

Evidence

  • Host checks. checks-and-stage.log at rev 2cfa788:
    • frames::: 41 passed, exit 0. vm::: 17 passed, exit 0.
    • The mutation exits 101 and is reverted; see BLOCKER 2 for what it shows.
    • Clippy exits 0 for x86, x86 with the actuator features, aarch64 and the library's tests.
    • This covers the touched pure modules, as the testing policy asks. The region path has no host test; the guest rows cover it.
  • Guest. Run 43081124750 at 2cfa788 is queued.
  • Metal A/B.
    • The sides are the PR heads. git diff pmm-batch-ab-a pmm-batch-ab-b is byte-identical to 2cfa788's diff. A is 1d151cb plus tests/toyos.rs (only the batch_ab registration), so both kernels are exactly the PR heads.
    • The procedure follows request5's rule: A B B A on each bench, two boots a side, the bench running alone, one image per side, and separate readback directories.
    • Gap 1: it waits on the guest runs. request5 names no guest run. Under the orchestrator's rule, A waits on 43080189189 and B on 43081124750, and both are still queued. The handoff says the T14 run has already been requested.
    • Gap 2: it does not read lock waits. The rows measure the throughput of work.build_shaped, spawn_cost and procs. The A/B can support a throughput claim, not a change in the lock's share of the waits, and the PR body does not claim the latter. The allocator record's exit ("lock waits read again") still needs a census boot later.

SEND BACK for BLOCKER 1 (carried by #1250's fix) and BLOCKER 2 (delete one test and one sentence). Once both are fixed, this is LAND pending guest run 43081124750 and the metal A/B, after #1250.

Japabu added a commit that referenced this pull request Nov 11, 2026
The benches are down, so the batched draw's A/B and the lock census are
owed rather than run: the frame allocator's record names the rows,
benches and what main must show.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review round 3 of #1268 at 1047f13 (base #1250 at 8245c9d; focused on round 2's points)

Architecture

Nothing structural changed. git range-diff against round 2's commit (2cfa788 on 1d151cb) shows two changes only:

The PR's own net growth falls by 10 lines.

Round-2 points

Findings

None.

Evidence

Since the specified head

pmm-batch has moved to 82555fb:

LAND after #1250, once guest runs 43080189189 and 43081124750 pass.

Japabu added a commit that referenced this pull request Nov 11, 2026
The frame allocator's record says the first decision is built here, #1265
having landed first, and names what main must show on metal for it: the
benches are down, so the A/B and the lock census are owed rather than run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #1268's rebase onto #1250 at 4e9f6f2 (main 8188095), at c00a628 (focused on the resolution)

Architecture

Nothing structural changed since round 3's LAND. The rebase only resolves conflicts, plus the record update that round 3 suggested. The PR's diff over #1250 is +110/−39 lines over 5 files.

The resolution

Findings

None. One cosmetic point, optional: line 77 of the allocator record runs two wrapped lines into one long line ("…has to reach them. The second, …"). It renders the same.

Evidence

  • Host, this review, at c00a628 in its own detached worktree and target directory (checks-1268.log):
    • vm:: 16 passed and frames:: 40 passed, exit 0 each. a_batch_of_promised_frames_draws_each_promise_once is among them.
    • Clippy with the adopted lints and -D warnings exits 0 for:
      • the library's tests with sched-check;
      • x86_64-unknown-none, with and without boot-actuators,test-actuators;
      • aarch64-unknown-none-softfloat, with and without boot-actuators,test-actuators.
    • The implementer's rebase-checks.log agrees.
  • Guest. Run 43112790573 is still queued.
  • Metal. Not a landing condition while the benches are down. The owed A/B and lock census are recorded.

LAND after #1250, once guest runs 43112778072 and 43112790573 pass.

Japabu added a commit that referenced this pull request Nov 11, 2026
The frame allocator's record says the first decision is built here, #1265
having landed first, and names what main must show on metal for it: the
benches are down, so the A/B and the lock census are owed rather than run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 12, 2026
The frame allocator's record says the first decision is built here, #1265
having landed first, and names what main must show on metal for it: the
benches are down, so the A/B and the lock census are owed rather than run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 12, 2026
The frame allocator's record says the first decision is built here, #1265
having landed first, and names what main must show on metal for it: the
benches are down, so the A/B and the lock census are owed rather than run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu and others added 2 commits November 12, 2026 09:00
Every frame a shared-memory region or a fault's populated run drew took
the frame allocator's one lock on its own: 512 holds for a 2 MiB region,
up to 16 for a fault. Under the build-shaped set at one child a CPU the
lock was 99 % of every contended lock wait, its waits 1.05 of the T14's
8 CPUs and 7.1 of the AMD bench's 16 for the whole round, 82 % of the
spin in the children's region creations and 13 to 14 % in their faults.

`Frames::alloc_promised_each` draws `count` promised frames in one call,
checking first that as many are promised; promises and ledgers move as
for as many single draws. `pmm::alloc_promised_many` draws a region's
zeroed frames and `pmm::alloc_promised_into` a run's unzeroed ones, a
superframe's worth (512) to a hold, so a large region still lets other
CPUs' draws in between. `SharedMemObject::create` takes a piece at a
time, and a fault counts its run's empty pages, draws them together and
writes each as its page starts (`fill_new`), which replaces `owned_frame`
and `pmm::alloc_promised_unzeroed`. Frees already went back in batches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The frame allocator's record says the first decision is built here, #1265
having landed first, and names what main must show on metal for it: the
benches are down, so the A/B and the lock census are owed rather than run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

1 participant