Repository navigation
A paged read takes two passes of its pager's lock and populates nothing ahead - #1245
Conversation
…es nothing ahead A cached read through a pager took its lock three times when one would do twice: to pin the chunk, to check after the copy that nothing ended or wrote meanwhile, and once more only to find the read done. On the T14 a `pread` of 8 bytes spent 49 ns of its 516 on that third pass. The check after the copy now answers the read when its chunk reached the read's end. Before copying a chunk of more than one frame, the read also faulted its destination in (`UserBytesMut::fault_in`): one hold of the space's lock and an inventory lookup and table walk per page, present or not. A cached 1 MiB read into a buffer it had read into before paid 34 to 74 ns a page on the T14 for nothing. It was there because a range of a memory object, such as the shared memory a spawn reads its image into, took a fault per page, where a private range's touch beside a populated page fills its 64 KiB run. Object ranges now map that run too, as the space's contract already said every page is populated, so the pass goes: a copy into memory nothing has touched takes the faults a touch would, one per run, and a copy into present pages takes none. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #1245 at 77f15f6ArchitectureThe PR deletes a compensating mechanism:
Kernel: +33/−55. The two lock passesUnder the old code, the third pass always answered When a read reaches EOF short, Concurrent withdraw and revoke:
The
|
The benches are down, so the paged read lands on guest evidence and its speed-up is unmeasured on metal. The user-memory record names the A/B of main from before and after it, its rows, both benches and what main must show; the large spawn's record names the price-row bound: `proc.spawn_wait.large` must not regress. That record no longer counts the populate pass this change removed among its hypotheses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Orchestrator verification, landing under the benches-down rule (metal is unavailable; landing on review + host + guest, metal owed):
|
…ved-write pieces Main moved 64 commits. Four files conflicted, and one call stopped compiling: - kernel/src/object/paged.rs: #1245's read returns once a copy reaches the read's end. Here that return sits where no write met the read, before it reads again. - kernel/src/syscall/paged.rs: #1246 looks a holder up under the handle table's own lock. The lookup keeps that form and checks the right each call needs (READ for a read and a statement, WRITE for a write). - lib/bcachefs/src/engine/tests/data.rs and the memory filesystem's record: both sides added beside each other, and both are kept. - #1216 split write_run into span, fill, seal and point_extent, and gave reserved writes their own planned path, dropping the reserved sector write_run took. The placed pool's writes take the sector their pool holds again: write_run has the optional placement it had, now over main's pieces, and the placed write checks the volume is writable as a reserved write does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cator's lock Main's populate already fills an object range's whole run (#1245), so a region written whole after it is made, such as a 32 MiB spawn image, drew a frame a page, one hold of the frame allocator's lock each: 8,192 where drawing the region whole took 16. The orchestrator decided to build the batch here rather than wait for #1259. `SharedMemObject::pages()` answers a run of pages at once, at most a fault's run, and `page()` goes through it. For a region, `slot::frames` takes every frame installed with one Acquire load each; if any page it was asked for is a hole it takes the region's first-touch lock, looks again, and has the holes still left drawn by one call, `pmm::alloc_promised_zeroed_into`: one hold of the allocator's lock for the draw, zeroing outside it, and one Release store a page to install. The object arm of `populate` asks for its run this way, and `create_whole` draws an inbox's rings a run at a time. The loom model gains two runs sharing a page: each page draws once, the shared page's one frame is handed to both, and each run draws in one call at most; `slot-install-relaxed` still reds the hand-over models. The first-touch record says it is built and carries what it owes on metal; the window record's two bullets go, the batch being built and the reading moved to the record of the change it measures. The slot's comments are trimmed as review r2 suggested. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lt-around does The rebase onto #1259 at main 06fcb81 kept step 2's per-arm `around` in `populate` so every replayed commit compiled; this restores #1245's shape on main, one `around` above the match for every kind of range, with the paged arm's index lookup beneath it. No behaviour changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…able one is shared and executable: /system text is ROOT's own pages Memory track stage 5 for `/system`, and step 1 of 7b (mapped paged objects). A running `/system` program's read-only segments were copied out of ROOT's boot pages a page at each first touch, so every process of one binary held its own copy of the text and paid a fault and a 4 KiB copy per page. They are now ROOT's pages, mapped. - ABI: `vm_map(object, offset, len, prot, at)` (SYS 106) replaces `shm_map`, which mapped a whole object anywhere. A range of whole pages of the object goes anywhere or at a fixed place, as `vm_reserve` places. - Kernel: one pure rule, `kernel::vm::Shared::maps`, decides what a space may map: executable only where nobody can write the object (a boot image or part of one), writable only where somebody can, a device's window whole. `SharedMemObject::mappable` applies it for `vm_map` and a spawn. (Object ranges' 64 KiB fault-around is main's, from #1245.) - Spawn: `SpawnMap::backing` says copy or share. `toyos::elf::program:: lay_out` shares a segment the program cannot write, whose bytes are its file's pages with nothing past them, from an image nobody can write (`Source::Immutable`), and copies everything else. kimage FORMAT 2 carries the word for the supervisor. - `toyos::dl` maps such a library segment from its immutable image and copies only the rest; a segment inside the window relocations write is never mapped. - A shared segment's mapping is never widened, so a program can no longer `mprotect` its own read-only segment writable; recorded in the memory track record. - Guest: `vm_map_shares` (x86-64, and `virt_vm_map_shares` on AArch64, where no other row maps a shared page executable): a range lands at its fixed place, a writable region is never executed, an image is executed and never written, and a program's read-only segments take no private frame and fault a store. - The memory track records the orchestrator's stage-6 decision: 7b's step 3 maps images from every pager-backed volume. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…able one is shared and executable: /system text is ROOT's own pages Memory track stage 5 for `/system`, and step 1 of 7b (mapped paged objects). A running `/system` program's read-only segments were copied out of ROOT's boot pages a page at each first touch, so every process of one binary held its own copy of the text and paid a fault and a 4 KiB copy per page. They are now ROOT's pages, mapped. - ABI: `vm_map(object, offset, len, prot, at)` (SYS 106) replaces `shm_map`, which mapped a whole object anywhere. A range of whole pages of the object goes anywhere or at a fixed place, as `vm_reserve` places. - Kernel: one pure rule, `kernel::vm::Shared::maps`, decides what a space may map: executable only where nobody can write the object (a boot image or part of one), writable only where somebody can, a device's window whole. `SharedMemObject::mappable` applies it for `vm_map` and a spawn. (Object ranges' 64 KiB fault-around is main's, from #1245.) - Spawn: `SpawnMap::backing` says copy or share. `toyos::elf::program:: lay_out` shares a segment the program cannot write, whose bytes are its file's pages with nothing past them, from an image nobody can write (`Source::Immutable`), and copies everything else. kimage FORMAT 2 carries the word for the supervisor. - `toyos::dl` maps such a library segment from its immutable image and copies only the rest; a segment inside the window relocations write is never mapped. - A shared segment's mapping is never widened, so a program can no longer `mprotect` its own read-only segment writable; recorded in the memory track record. - Guest: `vm_map_shares` (x86-64, and `virt_vm_map_shares` on AArch64, where no other row maps a shared page executable): a range lands at its fixed place, a writable region is never executed, an image is executed and never written, and a program's read-only segments take no private frame and fault a store. - The memory track records the orchestrator's stage-6 decision: 7b's step 3 maps images from every pager-backed volume. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cator's lock Main's populate already fills an object range's whole run (#1245), so a region written whole after it is made, such as a 32 MiB spawn image, drew a frame a page, one hold of the frame allocator's lock each: 8,192 where drawing the region whole took 16. The orchestrator decided to build the batch here rather than wait for #1259. `SharedMemObject::pages()` answers a run of pages at once, at most a fault's run, and `page()` goes through it. For a region, `slot::frames` takes every frame installed with one Acquire load each; if any page it was asked for is a hole it takes the region's first-touch lock, looks again, and has the holes still left drawn by one call, `pmm::alloc_promised_zeroed_into`: one hold of the allocator's lock for the draw, zeroing outside it, and one Release store a page to install. The object arm of `populate` asks for its run this way, and `create_whole` draws an inbox's rings a run at a time. The loom model gains two runs sharing a page: each page draws once, the shared page's one frame is handed to both, and each run draws in one call at most; `slot-install-relaxed` still reds the hand-over models. The first-touch record says it is built and carries what it owes on metal; the window record's two bullets go, the batch being built and the reading moved to the record of the change it measures. The slot's comments are trimmed as review r2 suggested. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cator's lock Main's populate already fills an object range's whole run (#1245), so a region written whole after it is made, such as a 32 MiB spawn image, drew a frame a page, one hold of the frame allocator's lock each: 8,192 where drawing the region whole took 16. The orchestrator decided to build the batch here rather than wait for #1259. `SharedMemObject::pages()` answers a run of pages at once, at most a fault's run, and `page()` goes through it. For a region, `slot::frames` takes every frame installed with one Acquire load each; if any page it was asked for is a hole it takes the region's first-touch lock, looks again, and has the holes still left drawn by one call, `pmm::alloc_promised_zeroed_into`: one hold of the allocator's lock for the draw, zeroing outside it, and one Release store a page to install. The object arm of `populate` asks for its run this way, and `create_whole` draws an inbox's rings a run at a time. The loom model gains two runs sharing a page: each page draws once, the shared page's one frame is handed to both, and each run draws in one call at most; `slot-install-relaxed` still reds the hand-over models. The first-touch record says it is built and carries what it owes on metal; the window record's two bullets go, the batch being built and the reading moved to the record of the change it measures. The slot's comments are trimmed as review r2 suggested. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cator's lock Main's populate already fills an object range's whole run (#1245), so a region written whole after it is made, such as a 32 MiB spawn image, drew a frame a page, one hold of the frame allocator's lock each: 8,192 where drawing the region whole took 16. The orchestrator decided to build the batch here rather than wait for #1259. `SharedMemObject::pages()` answers a run of pages at once, at most a fault's run, and `page()` goes through it. For a region, `slot::frames` takes every frame installed with one Acquire load each; if any page it was asked for is a hole it takes the region's first-touch lock, looks again, and has the holes still left drawn by one call, `pmm::alloc_promised_zeroed_into`: one hold of the allocator's lock for the draw, zeroing outside it, and one Release store a page to install. The object arm of `populate` asks for its run this way, and `create_whole` draws an inbox's rings a run at a time. The loom model gains two runs sharing a page: each page draws once, the shared page's one frame is handed to both, and each run draws in one call at most; `slot-install-relaxed` still reds the hand-over models. The first-touch record says it is built and carries what it owes on metal; the window record's two bullets go, the batch being built and the reading moved to the record of the change it measures. The slot's comments are trimmed as review r2 suggested. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cator's lock Main's populate already fills an object range's whole run (#1245), so a region written whole after it is made, such as a 32 MiB spawn image, drew a frame a page, one hold of the frame allocator's lock each: 8,192 where drawing the region whole took 16. The orchestrator decided to build the batch here rather than wait for #1259. `SharedMemObject::pages()` answers a run of pages at once, at most a fault's run, and `page()` goes through it. For a region, `slot::frames` takes every frame installed with one Acquire load each; if any page it was asked for is a hole it takes the region's first-touch lock, looks again, and has the holes still left drawn by one call, `pmm::alloc_promised_zeroed_into`: one hold of the allocator's lock for the draw, zeroing outside it, and one Release store a page to install. The object arm of `populate` asks for its run this way, and `create_whole` draws an inbox's rings a run at a time. The loom model gains two runs sharing a page: each page draws once, the shared page's one frame is handed to both, and each run draws in one call at most; `slot-install-relaxed` still reds the hand-over models. The first-touch record says it is built and carries what it owes on metal; the window record's two bullets go, the batch being built and the reading moved to the record of the change it measures. The slot's comments are trimmed as review r2 suggested. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Head ced7b1b: code-identical to 77f15f6, at which every check below ran (ced7b1b adds issue records only). It lands on guest evidence: both metal benches are down, and the speed-up is not yet measured on metal.
Problem
pipeperf's diagnosis of cached reads (tables and logs, stage timers in a scratch kernel, two boots per bench) found two costs in
PagerObject::readthat buy nothing:preadof 8 bytes on the T14: 516 ns against Linux's 340, of which the lock and pin passes were 2 x 49 ns (2 x 44 on the AMD bench), the check 31-34.UserBytesMut::fault_in): one hold of the space's lock, then an inventory lookup and a table walk for every page, present or not. Reading 1 MiB into a buffer it had read into before, that cost 547-1,185 ns per 16 pages on the T14 and 685-689 on the AMD bench (34-74 and 43 ns a page). The row: 132.7 µs against Linux's 103.5 on the T14.fault_inwas added (cfc30bd) because a range of a memory object, such as the shared memory a spawn reads its image into, took one copy fault per page: a 32 MiB spawn took 8,193. A private range does not, since a touch beside a populated page fills its aligned 64 KiB run (kernel::vm::around, a75abd3); object ranges were left out of that rule, though the space's module contract already says every page is populated at first touch or with that run.Change
Step::Copy { count, last }). A read now takes the pager's lock twice per chunk: once to pin, once to check.fault_ingoes, withUserBytesMut::fault_inandprocess::fault_in.AddressSpace::populateapplies the run rule to object ranges too: a touch beside a mapped page maps the object's pages of its aligned 64 KiB run, allocating nothing, since an object's frames are its own from creation. A copy into memory nothing has touched now takes the faults a touch would, one per run, private or object; a copy into present pages takes none.user-memory-hot-paths-are-slower-than-2-mib-pages.md(faults, copies) anda-large-spawn-on-the-amd-bench-is-a-seventh-slower-than-mains.md(the populate pass its figures were read with) say what the kernel now does, and what this change owes on metal.The cost moved: a first touch through a kernel copy pays an exception's delivery and return per 64 KiB run where
fault_inpaid one lock round trip per chunk. The A/B's fresh-memory rows (ab.fs.memory.fresh.*anonymous,ab.fs.memory.shm.*shared memory as a spawn's image is read into, up to 16 MiB) andproc.spawn_wait.largemeasure it.Checks
Targeted, per the owner's testing rules of 2026-11-11: the change is the paged read, the address space's fault service (shared by both architectures) and two records.
build/clippy.rs(x86-64: default,boot-actuators,boot-actuators,test-actuators; AArch64: default,boot-actuators,test-actuators) at 77f15f6: exit 0 on all five (log).kernel::vm::around, the run rule, is unchanged and tested inkernel/pure;populateand the paged read live in the kernel binary.a_spawn_from_an_image_frees_it_when_the_child_ends(a spawn reads its image through a paged read into fresh shared memory, so the copy faults on an object range and maps its runs),bar_map_againandbar_map_behind_a_bridge(a claimed BAR's aperture, an object range whose faults now map runs),a_user_copy_faults_in_mid_copy_or_ends_as_bad_address. AArch64, since the fault service is shared code:virt_user_copy_faults,virt_unmap_touch,virt_readonly_copyout,virt_user_mode,virt_claim_lpi. An earlier, wider run of mine (43051951823) was cancelled before it ran.osbench_pipeab_smokeat fa3e55b (this change + the measurement commit), run 43051958389: PASS, every row of the job measured, the fresh-memory and shared-memory reads among them (log). Correctness only: QEMU's timings are no evidence of speed.Owed on metal
The speed-up is a prediction from the diagnosis, not a measurement of this change. Recorded as Owed on metal, for when a bench returns:
osbench pipeab(the measurement-only commit onpipeperf-ab-base, run beside x86-64 user copies go by memcpy's rule, and a failed copy is a failure #1244's arm: 32 boots with the shared boots below), with the rule and the rows inissues/user-memory-hot-paths-are-slower-than-2-mib-pages.md. Main after must showab.fs.memory.pread.8.d0andab.fs.memory.pread.1048576.d0lower (predicted about 45 ns of 470-520, and 5-9%), andab.fs.memory.fresh.*andab.fs.memory.shm.*, the price, unchanged or their loss recorded against the gain.issues/a-large-spawn-on-the-amd-bench-is-a-seventh-slower-than-mains.md:proc.spawn_wait.largeandab.fs.memory.shm.16777216must not regress on either bench: no rise in all four paired boots with a median ratio above 1.03.testcasesandshared-debugboots of main, both benches.Risks
🤖 Generated with Claude Code