Repository navigation
blockring: the arena holds only what is on the wire, and a flush keeps many writes there - #1113
Conversation
a122cad to
6e85f74
Compare
Review of #1113 at 6e85f74 (round 1)ArchitectureThe direction is right. Placing transfers at send rather than at submit puts the arena where it belongs: it is a staging area for the wire, not a store for writes the device has not yet made durable. I traced the deadlock argument and it holds. Reissues sit at the front of the outbox, and nothing else is sent while they are outstanding. The only arena runs the session holds are runs of requests on the wire ( Contract check against the client. I went through every
So no ticket is retired while it is still queued for reissue, and The bounds hold:
The design misses one thing. The deleted flush-on-full was also the only bound on what a session retains, and nothing replaces it for callers other than the cache. Size: about +300 lines of production code (client +~100, session +~40, cache +~90, FindingsBLOCKER 1 —
BLOCKER 2 —
BLOCKER 3 —
BLOCKER 4 —
SUGGESTION
Evidence
SEND BACK |
This change is now stacked on #1099, whose cache allocates a block it reads in fallibly (`boxed`). Shared, that block goes into an `Rc` as its own box, which an `Rc` of the block itself could not do on stable Rust. So `SharedBlock` keeps its box, and its comment names that site instead of claiming an owner none had. The clean-blocks record's list of allocations with no refusal path follows the flush as it now is: its runs, the writes it keeps in flight, and each run's shares, plus a block's share count. A flushed block whose place in the eviction order is refused is dropped in `write_run`, as #1099's r3 did in the loop it replaces. #1113's cache tests pass the clean limit #1099's constructor takes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6e85f74 to
558ab77
Compare
Review of #1113 at 558ab77 (round 2, on #1099 at fe6d7d2)The r1 blockers
The cap's semanticsI went through every
Whenever the state is The accounting is consistent:
The Is 32 MiB justified?Yes. It is the smallest cap that adds no flush to the file server.
FindingsBLOCKER —
SUGGESTION
Evidence
SEND BACK — for the comment only. With it fixed and the queued metal run green, this lands. |
… to it `KEPT_BLOCKS`'s doc keeps only its reason: no less than the file server cache's dirty bound, so a cache flushing by that bound is never made to flush by the session's. The counts and timings behind the choice are #1113's: the 511-block arena bound forced 4 Flushes per 8 MiB of persist.seq_write_fsync, 0.95 ms each on the AMD bench and 3.58 ms on the T14. The cache now asserts `DIRTY_LIMIT <= KEPT_BLOCKS` at compile time, so the two crates' constants cannot drift apart silently. A write at the limit with a caller's flush on the wire waits for that flush rather than asking one of the client's own beside it. Only a flush on the wire counts: one queued behind the waiting write could never go out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #1113 at 20bdd14 (round 3)The r2 items
The new wait rule (
|
…ds one This deliberately departs from page-at-a-time placement, which r1 kept. The probe boots of #1113's head show what that placement does to persist.seq_write_fsync on metal. Each round's 8 MiB file lay in 103-106 runs, about 100 of them single blocks. Those are holes behind the allocator's cursor, where a commit's freed nodes are the likely source. So each round's flush took 163-169 requests, 100-105 of them single blocks. Round 0's whole-remainder ask instead gave 8 runs of 256 blocks and 64 full requests, and read 236 MB/s against 175 on the T14 (640 against 524 on the AMD bench), but it could scan the whole bitmap for each short run. `allocate` now asks each run to be at least `MIN_RUN` long, 32 blocks, the most one block-ring request carries, or the pages still missing if fewer. `alloc_first_fit` takes the first free run that long from the file's end, remembering the longest it saw. If one pass finds none it returns that longest, and the rest of the call takes runs as they come. A call is therefore at most two passes over the bitmap. Two host tests: single holes at the cursor before a long free run give a 64-page write one extent in the long run, where a page at a time takes the first hole; and on a volume of eight bitmap blocks with only single holes, a 16-page write reads the bitmap 55 times, within two passes and three reads a run (64). Without the minimum the first fails; without the fallback the second reads 167 times. The integration test that held page-at-a-time placement goes, as that placement is no longer the contract. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s many writes there An acknowledged write kept its arena blocks until a flush covered it, so it could be issued again after a loss. A session's arena is 511 blocks, and a full one made Served ask the disk for a Flush before the next request. On the AMD bench (probe at 21265a0) an 8 MiB fsync paid four such Flushes, about 0.95 ms each, besides its own two; on the T14, about 3.5 ms each. And the file server's cache wrote its runs one at a time. The contract is unchanged: a write acknowledged and not covered is issued again after a loss, and Durable means on the medium. Only where its bytes wait changes. The client now gives a read or a write its arena blocks as it goes on the wire (`next_request`'s `place`) and takes them back when it is answered, and keeps an acknowledged write by its ticket; the session keeps the caller's blocks for that ticket until the client retires it, copying them into the arena each time the write goes out. A full arena now only means requests on the wire hold it, and a request waits for one of their answers. Placing every transfer as it goes out, not at submit, is what keeps that wait from deadlocking: a write queued behind a reissue holds nothing the reissue needs. The blocks are shared, not copied: SharedBlock is an Rc of a boxed block, and the cache's slots are SharedBlocks, so a flush hands the disk the cache's own blocks (one copy, into the arena, where there were two). A write to a block the disk still keeps takes a block of its own. The flush keeps up to Disk::depth runs in flight, which a served disk answers as the full-size requests its session carries at once: min(DEPTH 64, 511 / 32) = 15, under the NVMe driver's 63 per queue. The swap track's design rested on acknowledged writes holding the arena; its two bullets now say what the session keeps instead. Review r1: the client carries each write's payload and bounds what it keeps Two blockers from review: a session's retention had no bound once the arena's was gone, and the model did not check what the session kept for a write. The client now carries the caller's payload with each write (`Ask::Write`'s `payload`, generic) and hands it to `place` each time the write goes out. The session's `held` map, `take_retired` and the `expect` for a write whose blocks were gone are deleted: a covered or given-up write drops its payload with it. No copy comes back; the session's payload is the cache's shared blocks. `Placing` is an enum of a read's ticket and a write's payload. What a client keeps is bounded where it is kept. Once acknowledged writes reach the limit it was made with, the caller's next write waits while the client sends a flush of its own. If that flush fails past MAX_ATTEMPTS, writes meeting the limit are answered Device unsent until a caller's flush succeeds, as the arena path refused them. The session's limit is 32 MiB, the file server cache's own dirty bound. The 511-block bound it replaces cost 4 Flushes per 8 MiB round on metal. The model runs a limit of one block, its write's payload being its ticket, and holds a new law: no caller's write first goes out while the client keeps its limit. Removing the wait breaks it. Dropping a payload at Done rather than at the covering flush breaks the durable law. Also: the module header states the payload contract once; cache.rs's header is rewrapped; `Disk::depth` says it is at least one and the `max(1)` goes; the swap record no longer derives a flush threshold from the arena, and says a swap-in read can wait behind a write. Review r1, stacked on #1099: This change is now stacked on #1099, whose cache allocates a block it reads in fallibly (`boxed`). Shared, that block goes into an `Rc` as its own box, which an `Rc` of the block itself could not do on stable Rust. So `SharedBlock` keeps its box, and its comment names that site instead of claiming an owner none had. The clean-blocks record's list of allocations with no refusal path follows the flush as it now is: its runs, the writes it keeps in flight, and each run's shares, plus a block's share count. A flushed block whose place in the eviction order is refused is dropped in `write_run`, as #1099's r3 did in the loop it replaces. #1113's cache tests pass the clean limit #1099's constructor takes. Review r2: `KEPT_BLOCKS`'s doc keeps only its reason: no less than the file server cache's dirty bound, so a cache flushing by that bound is never made to flush by the session's. The counts and timings behind the choice are #1113's: the 511-block arena bound forced 4 Flushes per 8 MiB of persist.seq_write_fsync, 0.95 ms each on the AMD bench and 3.58 ms on the T14. The cache now asserts `DIRTY_LIMIT <= KEPT_BLOCKS` at compile time, so the two crates' constants cannot drift apart silently. A write at the limit with a caller's flush on the wire waits for that flush rather than asking one of the client's own beside it. Only a flush on the wire counts: one queued behind the waiting write could never go out. On main after #1099 and the event doorbells (3e0fd67, f83957d), as one change: those turned the session's doorbell pipe into two events in the same functions this change edits (`pump`, `wait`, the submits and the session's fields). The session keeps main's `bells` and their `ring` and `answered` waits, and this change's payload placement and reads table around them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
20bdd14 to
4ea1637
Compare
…ds one This deliberately departs from page-at-a-time placement, which r1 kept. The probe boots of #1113's head show what that placement does to persist.seq_write_fsync on metal. Each round's 8 MiB file lay in 103-106 runs, about 100 of them single blocks. Those are holes behind the allocator's cursor, where a commit's freed nodes are the likely source. So each round's flush took 163-169 requests, 100-105 of them single blocks. Round 0's whole-remainder ask instead gave 8 runs of 256 blocks and 64 full requests, and read 236 MB/s against 175 on the T14 (640 against 524 on the AMD bench), but it could scan the whole bitmap for each short run. `allocate` now asks each run to be at least `MIN_RUN` long, 32 blocks, the most one block-ring request carries, or the pages still missing if fewer. `alloc_first_fit` takes the first free run that long from the file's end, remembering the longest it saw. If one pass finds none it returns that longest, and the rest of the call takes runs as they come. A call is therefore at most two passes over the bitmap. Two host tests: single holes at the cursor before a long free run give a 64-page write one extent in the long run, where a page at a time takes the first hole; and on a volume of eight bitmap blocks with only single holes, a 16-page write reads the bitmap 55 times, within two passes and three reads a run (64). Without the minimum the first fails; without the fallback the second reads 167 times. The integration test that held page-at-a-time placement goes, as that placement is no longer the contract. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem
persist.seq_write_fsync, 8 MiB written in 1 MiB calls and then fsynced, read 316 MB/s on the AMD bench and 148 MB/s on the T14. Linux reads 506–520 and 527–598 MB/s on the same NVMe.A measurement-only boot per bench attributed each 8 MiB round (
probe/attribution.txt, medians of three rounds; AMD then T14):Copying into the cache is 1.5–2.3 ms, not the 18 ms first supposed. The cost is in two places:
Servedask the disk for a Flush before the next request.Change
The contract is unchanged: an acknowledged write that no flush has covered is issued again after a loss, and
Durablemeans on the medium. What changes is where a write's bytes wait.toyos_blockring::client: the arena holds only what is on the wire.next_request'splace) and gives them back when it is answered.Ask::Write { payload }, generic: the session's shared blocks, a ticket in the model) and hands it toplaceeach time the write goes out. A write answered anything butDone, covered by a flush, or given up drops its payload with it. Nothing outside the client keeps a write's bytes, so none can outlive it or be missing at a reissue (review r1).MAX_ATTEMPTS, writes meeting the limit are answeredDeviceunsent until a caller's flush succeeds, as the arena path refused them. The session's limit,KEPT_BLOCKS, is 32 MiB, no less than the cache's own dirty bound, so a cache flushing by that bound never meets it; the cache assertsDIRTY_LIMIT <= KEPT_BLOCKSat compile time (review r2). A write at the limit with a caller's flush on the wire waits for that flush rather than asking one of the client's own (review r2); only a flush on the wire counts, since one queued behind the waiting write could never go out. The 511-block bound it replaces cost 4 Flushes per 8 MiB round (0.95 ms each on AMD, 3.58 ms on the T14). An update streaming ROOT keeps at most 32 MiB, where without the limit it kept the whole image.diskserver::Sessionhands the client the caller's blocks. A write'sSharedBlocks (anRcof a boxed block) are its payload, copied into the arena each time it goes out. The block is boxed so the cache can allocate a block it reads in fallibly (fileserver: a volume's cache keeps an eighth of physical memory clean #1099'sboxed).Unsent::ArenaFullis gone, and so isServed's flush-on-full path.Served::writecopies into shared blocks andServed::write_sharedtakes them as they are, the one write path.Served::depth()is the full-size requests a session carries at once: min(ring depth 64, 511 / 32) = 15. The NVMe driver queues 63.depthruns in flight.SharedBlocks, so a flush hands the disk the cache's own blocks: one copy, into the arena, where there were two.Disk::depthruns in flight. Once one is refused no other is begun, and the blocks not written stay dirty.Disk::write_shared's default) is handed a copy, as before.issues/anonymous-memory-swaps-to-an-encrypted-partition.md) rested on acknowledged writes holding the arena. Its "free after durable" and arena-reserve bullets now say what the client keeps, so "free after durable" holds unchanged. Its 223-write flush threshold came from arena arithmetic that no longer applies, and is gone; the batch's own flush bounds what the service keeps. A swap-in read can now wait behind a write: requests go out in the order asked, so a read asked after a write waits while that write has no contiguous arena room or waits at the kept limit. The record says so and names the remedy, reads ahead of waiting writes, should swap-in latency need it.Checks
Evidence: https://github.com/ToyOSOrg/evidence/tree/37b67d1013ce98de4fb496f93834162691676a41 (host, red controls, probe) and https://github.com/ToyOSOrg/evidence/tree/dc209f847cc2f2c1a919abf0b60bdaf3a040f455 (metal A/B, rebase, the next steps' host probes) and https://github.com/ToyOSOrg/evidence/tree/36653291e4c09bac905ef567c8d2fe2e193ae2a5 (review r1:
r1/)cargo run -- --ci hostexit 0, 107 green. diskserver, blockring with the model, fileserver and blockdev tests all pass (evidence https://github.com/ToyOSOrg/evidence/tree/3dc5fbae756ffd6300fcd7a8af9bc1fa6aea378b,onmain/).system/diskserver/src/session.rs, against main's 3e0fd67 (a session's doorbell pipe became two events,bells.ringandbells.answered) and f83957d (the handshake's timed wait). Both edit the functions this change edits.pumpringsbells.ring,waitwaits onbells.answered, and the submits checkbells. Around them it keeps this change's payload placement, reads table,KEPT_BLOCKSandPending::Read.seq_write_fsync181 MB/s and AMD 588, within 20bdd14's boots (175–233 and 524–657).seq_write.4096reads 170 and 297. Everypersist.*row is within or better than that spread, but T14fsync_cleansits 0.05 µs past it (14.38 against 14.33), within resolution. Evidence https://github.com/ToyOSOrg/evidence/tree/24e490febee1909deecd9c975fecba8db4e82ba6 (onmain/).KEPT_BLOCKSdoc keeps only its reason, with the counts and timings in the commit; the compile-time bound check; the wait for a caller's flush on the wire.cargo run -- --ci hostexit 0, 105 green, andclient::tests::a_write_at_the_limit_waits_for_a_callers_flush_on_the_wirewith its red control (evidence https://github.com/ToyOSOrg/evidence/tree/ff9a0fea3e4ceac3122f8eb7d5b2eb9ce2a30940,r2/).cargo run -- --ci hostexit 0, 105 green (r1/ci-host.log). The stack's conflict is in cache.rs: fileserver: a volume's cache keeps an eighth of physical memory clean #1099's fallibleboxednow goes into the shared slot, and fileserver: a volume's cache keeps an eighth of physical memory clean #1099 r3's order reservation for a flushed block moves intowrite_run.cargo testfor blockring (31), diskserver (15), fileserver (90) and blockdev (13): all pass. The first commit alone does not build its tests on fileserver: a volume's cache keeps an eighth of physical memory clean #1099, because fileserver: a volume's cache keeps an eighth of physical memory clean #1099'sCache::newtakes a limit; the third commit fixes that.cargo run -- --ci hostexit 0 there, 105 steps green (rebase/). Before the rebase, at a122cad:--ci hostexit 0, 105 green (host/), andcargo run -- --build-onlyexit 0. The rebase touches none of blockring, the disk server or the cache. Under them it brings bcachefs: a sync with nothing uncommitted writes no superblock #1098's clean-fsync change (bcachefs, data.rs), and beside them TCP, Wi-Fi and osbench changes. So the A/B below compares this change's own effect at a122cad; 6e85f74 itself has not been booted.client::tests::a_write_holds_arena_blocks_only_on_the_wire,a_request_without_arena_room_waits_for_it, anda_lost_flush_reissues_then_asks_again, now placing the reissue with its own payload.client::tests::writes_past_the_limit_wait_for_the_clients_own_flush(32 writes, no caller flush: the client never keeps more than its limit, and the caller hears only its writes) anda_failed_own_flush_refuses_writes_until_a_flush_succeeds. The limit lives in the client, so these test it there.Served'sScriptlink stands in for the session and has no client.cache::tests::a_flush_keeps_as_many_runs_in_flight_as_the_disk_takes,a_flush_shares_the_cache_s_blocks_with_the_disk,a_refused_run_begins_no_other_and_leaves_the_rest_dirty.lib/blockring/src/model.rs, every interleaving of client, server, device, resets, crashes and errors) runs the changed client, a write's payload being its ticket. That payload is what the device writes, so a reissue with the wrong bytes breaks the durable law. Review r1: it runs a limit of one block, so every script meets it, and holds a third law: no caller's write first goes out while the client keeps its limit. All laws hold. Red controls (r1/): removing the wait breaks the new law (red-k1), and dropping a payload atDonerather than at the covering flush breaks the durable law (red-k2). The CI's three mutations still fail it (red/red-mutate-*).red/red-m*): the depth ignored, copies handed instead of shares, a run begun after a refusal, a write's run held until the flush.readsby ticket and its arena copies. They map shared memory and run only in a guest; the metal A/B runs them.heldis gone.Metal under the limit (X-B2, passed on both benches). Base #1099 fe6d7d2 against this head 20bdd14 with the measurement-only probe. Evidence https://github.com/ToyOSOrg/evidence/tree/95b0e454d9fe5dd7d00ab7bc69638ca56631c85a (
r3/).AMD:
persist.seq_write_fsync286 → 527 MB/s,fs.disk.seq_write.1048576332 → 737,.4096201 → 269. Everypersist.*latency row is level or better.fs.disk.rename62.1 → 72.5 µs, though the r1 boot with the same probe read 61.8 and differs only by r2;persist.seq_read_cached3,983 → 3,132 MB/s, inside earlier AMD boots' 2,911–3,315. Boot spread, not attributable.T14:
persist.seq_write_fsync149 → 193 MB/s, within this code's other T14 boots (175–208).write_4k_fsyncandappend_4k_fsyncread 1.1 ms in the base boot and about 6 ms in every boot of this code. That is the T14's per-boot durable-write mode (issues/toyos-durable-writes-on-the-t14s-nvme-run-six-times-slower-in-some-boots.md); a base boot read 6.5 ms in round 0.fs.disk.seq_write.1048576(64 MiB in 1 MiB writes) reads 258–260 in four of five rounds and 401 in one. Every other T14 boot of this code reads 435–501.fs.disk.mkdir_rmdir, which never reaches the cap.r3/t14-boots.txt, every T14 boot that day):mkdir_rmdirmoved with them, 86.6 → 108–110 µs, on a path r2 cannot reach.Metal A/B, round 0 (
metal/): base f407918 against a122cad, onebenchcaseboot each per bench, plus a second AMD base boot from #1099's landing control. All passed. p50s:persist.seq_write_fsync(MB/s)fs.disk.seq_write.1048576(MB/s)fs.disk.seq_write.4096(MB/s)seq_write_fsyncin earlier boots; these boots ran no Linux. AMD is now at or above that; the T14 is at about a third of it.persist.*andfs.disk.*row is level or better. On the T14,create_write_unlink(126 → 160 µs) andopen_close(22.3 → 26.2 µs) read high, but inside the range four boots of one tree show (123–159 and 21.9–26.9;t14-metadata-spread.txt). The T14's metadata rows are bimodal per boot.rename96.6 µs is 7% above that range, on one boot.fs.mem.open_close,fs.mem.stat,pingpong.futex.same_cpuandproc.spawn_wait.largelie within those boots' range or at most 2% past it.pipe.throughput.4096reads 1,904 MB/s against 2,061–2,110 in six boots, 8% low on one boot. This change touches no pipe.fs.disk.seq_read.4096rose 165 and 152 → 447 MB/s, unexplained, and not claimed. The 64 MiB file sits exactly at the 16,384-block clean limit, so the row is on a knife edge. The host model of it reads the whole file from disk every round both before and after this change (plan/probe-*.log). fileserver: a volume's cache keeps an eighth of physical memory clean #1099 removes the edge.Next, as separate changes
🤖 Generated with Claude Code