Repository navigation
bcachefs: a write's pages are allocated at once, one bitmap write per block of bits - #1118
Conversation
Review r1 of #1118 (head fb5adb5, base
|
fb5adb5 to
af6d088
Compare
Review r2 of #1118 (head af6d088, base
|
… took DATA's write allocates every page past the file's extents and then writes the pages through the cache. When a page's cache write, or the read of a page written in part, failed, the call returned the error with the pages it never wrote still in the extents, past the file's length. Those blocks held what their last file left there. A later extending truncate, or a write beyond them, brought them under the file's length, and since `allocate` zeroes only from the extents' end, a read returned the deleted file's bytes: one file's contents shown to another's reader. The allocation and the copy now feed one result, and any refusal drops the extents back to their length before the call and frees the rest, as a refused allocation already did. Pages written over in place keep what the call wrote, as before. The host test deletes a 512-page file of 0xAB, then writes half of a new file's page 0, whose read the disk refuses, and page 1, allocated first. It extends the file and reads zeros in page 1. Before the fix page 1 read 0xab. Found in review of #1118 (`issues/a-write-that-fails-after-allocating-...`, deleted here as fixed). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
20bdd14 to
4ea1637
Compare
… block of bits The file server's write resolved each page past the file's end on its own: for each, a bitmap read to see whether the block after the file was free, another to find a run of one, and a read-modify-write of the bitmap block to mark it. persist.seq_write_fsync's probe boots put this at 2.4 ms per 8 MiB on the AMD bench and 6.7 ms on the T14. `Mounted::allocate` covers a write's whole range of pages. Each run asks for every page still missing, and only the hole before the write's first page is zeroed, since the caller writes the rest. `set_range_used` marks a run with one read-modify-write per bitmap block its bits lie in. `resolve_or_alloc_block` is the one-page case. `DataVolume::write` makes one `allocate` and one `file_entry_fits` check per call, and frees the call's blocks if either refuses, as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nmarked Asked for every page still missing, `longest_free_run` stops early only at a run that long; on a fragmented or nearly full volume each short run cost a scan of the whole bitmap, k runs k scans. `Mounted::allocate` now takes each run first fit (`alloc_first_fit`): the first free block at or after the file's end, on to the end of its run or every page missing. That is one pass over the blocks a call walks, and it places blocks exactly as a page at a time did. A host test grows twin volumes with a 4-block hole before a long free run, page by page and in one call, and finds the same extents; asking for the longest run again fails it. `set_range_used` can now mark several bitmap blocks for one run. When the device refuses one, the blocks already marked are cleared again, so a refused run leaves no blocks marked that nothing names. Two tests cover a volume of two bitmap blocks: a run across the boundary writes each once, and a run refused at the second leaves the free count as it was. `resolve_or_alloc_block` had only test callers and is deleted; they call `allocate(p..=p)`. `allocate`'s doc states the caller's duty, writing every page of the range and zero-filling what it does not cover. Two stale comments are fixed: the commit header's in-place write now names `block_for`, and the integration test's `write_pages` no longer claims a kernel path. The review also found a pre-existing defect: a write that fails after allocating leaves another file's bytes past the end. It is recorded and assigned to the next PR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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>
8be159d to
bea91a3
Compare
… took DATA's write allocates every page past the file's extents and then writes the pages through the cache. When a page's cache write, or the read of a page written in part, failed, the call returned the error with the pages it never wrote still in the extents, past the file's length. Those blocks held what their last file left there. A later extending truncate, or a write beyond them, brought them under the file's length, and since `allocate` zeroes only from the extents' end, a read returned the deleted file's bytes: one file's contents shown to another's reader. The allocation and the copy now feed one result, and any refusal drops the extents back to their length before the call and frees the rest, as a refused allocation already did. Pages written over in place keep what the call wrote, as before. The host test deletes a 512-page file of 0xAB, then writes half of a new file's page 0, whose read the disk refuses, and page 1, allocated first. It extends the file and reads zeros in page 1. Before the fix page 1 read 0xab. Found in review of #1118 (`issues/a-write-that-fails-after-allocating-...`, deleted here as fixed). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bea91a3 to
09203d2
Compare
…said r2 claimed at most two passes over the bitmap a call, and that was false. After a run, the next search began past the file's taken next block, by SPREAD, and could wrap a whole lap to reach free space behind it. Free space clustered in fewer than 256 blocks cost a lap a run, in both phases. Now each phase examines at most one lap of the bitmap for the whole call. `alloc_first_fit` takes a budget of blocks to examine, shared across the phase's runs, and answers `None` when it saw nothing free within it. The first phase looks for runs `min_run` long from the file's end, jumping SPREAD past a taken next block as before. When it exhausts its lap without such a run, it takes the longest it saw. The second phase then takes runs as they come, sweeping forward from the last run's end without the jump. A call reads at most two laps, besides the blocks it takes. `min_run` is the caller's: the file server passes the block ring's request size, `toyos_blockring::MAX_REQUEST_BLOCKS`, so bcachefs no longer repeats it. The two corrected claims: the test leaves 31 single holes, not 32, and the docs state what the code does, so single-block holes do not each become a run, not their cause. Tests: - the reviewer's two clustered layouts on a volume of eight bitmap blocks, 64 single holes in 128 blocks, and eight runs of 40 one block apart, each held to two laps of reads besides three a run; - single holes everywhere, held to the same. Red controls: phase two jumping SPREAD fails the clustered tests, a budget per run instead of per call fails the long-run cluster (71 reads for 7 runs), and runs of any length fail the single-holes skip test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
09203d2 to
1eb1a9f
Compare
… took DATA's write allocates every page past the file's extents and then writes the pages through the cache. When a page's cache write, or the read of a page written in part, failed, the call returned the error with the pages it never wrote still in the extents, past the file's length. Those blocks held what their last file left there. A later extending truncate, or a write beyond them, brought them under the file's length, and since `allocate` zeroes only from the extents' end, a read returned the deleted file's bytes: one file's contents shown to another's reader. The allocation and the copy now feed one result, and any refusal drops the extents back to their length before the call and frees the rest, as a refused allocation already did. Pages written over in place keep what the call wrote, as before. The host test deletes a 512-page file of 0xAB, then writes half of a new file's page 0, whose read the disk refuses, and page 1, allocated first. It extends the file and reads zeros in page 1. Before the fix page 1 read 0xab. Found in review of #1118 (`issues/a-write-that-fails-after-allocating-...`, deleted here as fixed). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem
persist.seq_write_fsyncwrites 8 MiB in 1 MiB calls and fsyncs. #1113's probe boots attributed 2.4 ms per 8 MiB round on AMD, and 6.7 ms on the T14, to the file server's allocation (write/attribution.txtin #1113's evidence).DataVolume::writeresolved each page past the file's end on its own (resolve_or_alloc_block). Each page cost a bitmap read to see whether the block after the file was free, another to find a run of one, and a read-modify-write of the bitmap block to mark it, all through the cache: four 4 KiB copies per 4 KiB written.Change
Mounted::allocate(extents, pages)covers a write's whole inclusive range of pages. Each run asks the allocator for every page still missing, and only the hole before the write's first page is zeroed, since the caller writes the rest.resolve_or_alloc_blockis its one-page case.set_range_usedmarks a run with one read-modify-write per bitmap block its bits lie in, where it took one per block.DataVolume::writemakes oneallocateand onefile_entry_fitscheck per call. If either refuses, it frees the call's blocks, as before. A write of no bytes allocates nothing.resolve_or_alloc_blockas the write path now nameallocate, orblock_forwhere they meant the lookup.Placement deliberately departs from a page at a time (review r2, the coordinator's decision). Each run must be at least
min_runblocks long, or the pages still missing if fewer. The file server passes the block ring's request size (toyos_blockring::MAX_REQUEST_BLOCKS, 32).alloc_first_fittakes the first free run that long from the file's end, or the longest it saw once its scan budget is spent, so single-block holes do not each become a run.r2/). Page-at-a-time placement, and r1's first fit that reproduced it, left each 8 MiB round's file in 103–106 runs, about 100 of them single blocks: holes behind the allocator's cursor, where a commit's freed nodes are the likely source. So a round's flush took 163–169 requests, 100–105 of them single blocks. Round 0's whole-remainder ask gave 8 runs of 256 and 64 full requests.persist.seq_write_fsyncon the T14 read 175 MB/s page at a time, 208 with r1's single allocation call, and 236 with long runs; on the AMD, 524/527, 657 and 640.min_runlong, with one budget across all its runs. Once that is spent, the second phase takes runs as they come, sweeping forward from the last run's end without the jump, within one more lap. A call reads at most two laps of the bitmap, besides the blocks it takes.set_range_usedcan now mark several bitmap blocks for one run; if the device refuses one, the blocks already marked are cleared again.resolve_or_alloc_blockis deleted: only tests called it, and they callallocate(p..=p).allocate's doc states the caller's duty: write every page of the range, zero-filling what it does not cover. Two stale comments are fixed: the commit header's in-place write namesblock_for, andwrite_pagesno longer claims a kernel path.issues/a-write-that-fails-after-allocating-leaves-another-files-bytes-past-the-end.md) and fixed in the PR stacked on this one: a write that fails after allocating left another file's bytes past the end.Checks
Evidence: https://github.com/ToyOSOrg/evidence/tree/57c9e8245fe88f24e7c8d81c711bd3cfabccfc87 (round 0) and https://github.com/ToyOSOrg/evidence/tree/6e645aafbeeb932ad9748d2ddc34a3bf6c8102e8 (review r1,
r1/) and https://github.com/ToyOSOrg/evidence/tree/22d33ce808000fc08b5d353d25bc418bbfde975f (review r2,r2/)allocate, phase one's budget is exactly one lap, so the join never ran, and phase two's minimum of one never needs it. Also,allocate's two conditions onsweepare one match, and the file server's call says why a run is one request long. X-F (bea91a3 plus the probe) still measures this behaviour, because the deleted code never ran.--ci hostexit 0, 107 green at 09203d2; the head only rewraps one doc comment (evidence https://github.com/ToyOSOrg/evidence/tree/9eadefc77139ff66bf6de52ae4967de529bc4045,r4/).--ci hostexit 0, 107 green. The first run was red on a clippy lint in two new tests, fixed by the amend (evidence https://github.com/ToyOSOrg/evidence/tree/ddef8f0232552826f5e80151c41293071276dd5a,r3/). New tests, each with a red control:a_write_into_one_cluster_of_single_holes_reads_two_laps_at_most, 64 single holes in 128 blocks;a_write_into_one_cluster_of_long_runs_reads_two_laps_at_most, eight runs of 40 one block apart;a_write_on_a_volume_of_single_holes_reads_two_laps_at_most, replacing r2's single-holes test.red-r7) fails both clusters; a budget per run (red-r8) reads 71 times for 7 runs, against 39.red-r5).ci-host-r3.log). After the straddle join's deletion, 182 unit tests and fileserver 90 pass.seq_write_fsync233 MB/s. AMD 9 runs, 70 requests,seq_write_fsync639 andseq_write.1048576913. r3 changes the search, so the head reran on metal:r4/):seq_write_fsync: T14 493 MB/s against 181, AMD 631 against 588. The T14 boot ran in its fast durable-write mode.persist.*rows lie more than 5% past blockring: the arena holds only what is on the wire, and a flush keeps many writes there #1113's spread. T14fsync_clean15.84 µs follows the T14's durable-write mode: the other fast-mode boot read 16.07. AMDseq_read_cached2,713 reads from the cache without allocating, and that day's AMD boots of unrelated trees range 2,217–3,290.fs.disk.seq_write.4096's file has 15–20 runs. The minimum run cannot apply to a one-page write.--ci hostexit 0, 105 green. The first run was red on a clippy lint in the new test (ci-host-r2.try1.log), fixed before the push. New tests, each with a red control:fs::tests::a_write_skips_single_holes_for_a_run_a_request_long: 31 single holes at the cursor, then a long free run, and a 64-page write takes one extent in the long run, where a page at a time takes the first hole. Red: runs of any length (red-r5).fs::tests::a_write_on_a_volume_of_single_holes_passes_its_bitmap_twice_at_most: eight bitmap blocks, only single holes. A 16-page write reads the bitmap 55 times, within two passes and three reads a run (64). Red: a pass per run, 167 reads (red-r6).a_range_is_placed_as_its_pages_one_at_a_time_would_be) is deleted: that placement is no longer the contract.--ci hostexit 0, 105 green. New tests:a_range_is_placed_as_its_pages_one_at_a_time_would_be: twin volumes with a 4-block hole before a long free run give a 100-page file the same extents page by page and in one call. Red: the longest-run ask (red-m3).alloc_bitmap::tests::a_run_across_two_bitmap_blocks_writes_each_once, on a 40,000-block volume, which has two bitmap blocks.a_run_refused_halfway_leaves_nothing_marked. Red: no undo (red-m4).cargo run -- --ci hostexit 0, 105 steps green (alloc/ci-host.log).fs::tests::a_range_of_pages_is_allocated_with_one_write_per_bitmap_block, over theCounteddevice that records each block written.alloc/), each a patch on the change, applied, run withcargo test -p bcachefs --lib, then reverted:red-m1-per-block-bitmap-writes:set_range_usedwrites per block. Exit 101; the new test fails.red-m2-one-page-per-run:allocateasks for one page per run, the per-page loop. Exit 101; the new test fails.DataVolume::write's single call is checked by inspection: it callsallocateonce, outside any page loop.cargo test -p bcachefs(176 + 57 + 2 + 2) andcargo test -p fileserver(90) pass.persist.seq_write_fsyncandfs.disk.seq_write.*. The probe logs each round's file runs and flush run lengths, for the fragmentation question (below).Not in this change
The write-failure leak the r1 review found is fixed in #1120, stacked on this.
🤖 Generated with Claude Code