perf(umbp): hardware CRC-32C and direct I/O for the SSD tier - #526
Open
TianDi101 wants to merge 3 commits into
Open
perf(umbp): hardware CRC-32C and direct I/O for the SSD tier#526TianDi101 wants to merge 3 commits into
TianDi101 wants to merge 3 commits into
Conversation
segment::CrcUpdate was a bitwise, table-less CRC-32/ISO-HDLC -- 8 shift/XOR per byte, ~152 MB/s on one core -- run over every byte of every record on both the read and the write path. On the SSD tier it dominated everything else: the checksum, not the drive, set the tier's ceiling. Two changes, both confined to the segment layer: * CRC-32C via the SSE4.2 crc32 instruction, with __builtin_cpu_supports runtime dispatch and a byte-identical portable table fallback. The fallback matters for correctness, not just portability: a segment written on a host with hardware support must still verify on one without, so both paths are pinned to the same reflected polynomial (0x82F63B78) and tested against an independent bitwise reference. ~152 MB/s -> ~13.4 GB/s. No -msse4.2 on the translation unit; the dispatch is per-call and predicted. * Split segment::Writer::Prepare into Build (checksum + record assembly) and Reserve (index reservation). SSDTier now runs Build outside mu_ and only Reserve under it, so a write batch no longer blocks concurrent reads on the same drive for the whole of its CRC + copy time. Prepare is kept as Build+Reserve for callers already holding the lock. Build leaves `generation` zero and Reserve patches it in place, since it is the one header field that does not exist until the reservation is taken. kRecordVersion 1 -> 2: the polynomial changed, so v1 checksums must not be verified with the v2 routine. The scanner's existing version check drops them and the segment refills -- this tier is a cache, so its contents are always re-fetchable. Tests: new test_segment_crc pins the standard CRC-32C check value 0xE3069283 (which also catches an accidental revert to ISO-HDLC, whose check value is 0xCBF43926), verifies the selected path against an independent bitwise reference at every tail length mod 8 and at unaligned starts, checks streaming composition, and covers the Build/Reserve generation stamping. test_ssd_tier passes unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A segment whose head the scanner cannot parse silently swallows every subsequent write. The scanner stops at the first unreadable header and returns that offset as scanned_offset, but write_offset is set from the file size, so the writer happily appends past the junk. On the next open the scanner stops in the same place again -- so every key written in between is gone, and it stays gone for the life of the directory. Nothing upstream catches this. PeerSsdManager::DiscardLeftoverOnStartup only wipes when Capacity() reports used > 0, and a scan that broke at offset 0 reports used == 0 -- so it logs "no SSD leftover" and skips the wipe. The failure surfaces as a 0% SSD hit rate with no error anywhere. Two ways in: a record written by an older kRecordVersion (the v1 CRC-32/ISO-HDLC format that preceded CRC-32C is now exactly this case), and a torn tail record from a crash mid-write. Both are repaired the same way -- ftruncate the segment to the last record the scanner could parse and reset the append cursor to match. Safe by construction: this tier is a cache and its contents are re-fetchable. Owner-only. A read-only follower must never rewrite the shared log, and the owner runs this once at construction, before any write can land. Tests: test_segment_with_unreadable_head_is_reclaimed writes a key, rewrites its header's version field to an unsupported value (what a pre-upgrade segment looks like to the current scanner), then reopens and writes a second key. Without this change the second key fails to read back after the next restart; the test is pinned to that assertion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The SSD tier is itself a cache, so buffered I/O gives it a second, unmanaged DRAM cache underneath. Reads get served from the page cache at many times device bandwidth, the node reports DRAM it is actually consuming as free, and any measurement of drive behaviour is meaningless -- a "1 drive vs 2 drives" comparison can show no difference simply because neither run touched a drive. ssd.direct_io=true (UMBP_SSD_DIRECT_IO=1) bypasses it, so what the tier reports is what the device did. O_DIRECT requires the buffer address, the file offset and the length to all be alignment multiples, which the previous record layout satisfied for none of the three. Hence: * Record layout v3, padded to a fixed kRecordAlign of 4096: [header|key|pad][value|pad]. Every record starts, and every value starts and ends, on a boundary. The alignment is a fixed constant rather than the device's reported requirement, so a directory keeps its meaning when it moves between devices; 4096 covers every realistic case. Padding is unconditional, not gated on direct_io, so one directory is readable with either setting -- only the reader's buffer alignment differs. * AlignedBuffer, a posix_memalign-backed buffer replacing std::vector<char> for prepared records and scanner reads (std::vector only guarantees 16 B). It zeroes padding, so record padding cannot leak heap contents to the device. * Startup probe rather than a trusted capability bit: open a probe file with O_DIRECT and round-trip one aligned block. This catches tmpfs and overlayfs (which reject the open) and devices needing coarser alignment. On failure the tier logs a warning and runs buffered instead of failing to come up. * A bounce path for reads whose destination is not aligned. KV page buffers normally are, so this is the exception -- but it has to exist, because without it the failure is an EINVAL that the per-key fallback would just repeat, surfacing as a silent 100% miss rather than an error. Two knobs come with it, both needed to interpret the results. ssd.verify_crc isolates checksum cost from storage cost (records written with it off carry kFlagNoCrc and stay readable by a reader with it on). ssd.tier_io_threads fans the tier's CPU-bound phases out over 4 workers by default, matching the DRAM tier's read_threads_/write_threads_, so a DRAM-vs-SSD comparison does not silently measure 4 threads against 1. Capacity is now charged in padded bytes, since that is what the device actually gives up. For small values this is a real increase -- a 512 B value occupies 8192 B once its prefix and value are each padded. segment_size_bytes must now be a multiple of 4096; otherwise the append cursor lands unaligned at segment roll-over. Validate() rejects it rather than failing at the first direct write. Tests: new test_ssd_direct_io covers the aligned layout invariants, AlignedBuffer, a buffered/direct round trip (aligned and unaligned destinations, so both the direct and the bounce path), the verify_crc=false round trip, thread-count invariance of results, and padded capacity accounting. It skips rather than fails where the filesystem cannot do O_DIRECT. Verified with the probe genuinely active, not falling back. Full umbp ctest 38/41; the 3 failures are the pre-existing "no active RDMA device" container limitation, thrown in SetUp() before any tier code runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Extracts three self-contained pieces of SSD-tier work from a larger pure-SSD branch onto
main. Nothing from that branch's pure-SSD mode, multi-drive sharding, hugepage staging,UMBP_SSD_TIMINGinstrumentation orUMBP_SSD_DURABILITYknob is included here.1. Hardware CRC-32C (
9573db2)segment::CrcUpdatewas a bitwise, table-less CRC-32/ISO-HDLC — 8 shift/XOR per byte, ~152 MB/s on one core — run over every byte of every record on both read and write. On the SSD tier the checksum, not the drive, set the ceiling.Replaced with CRC-32C via the SSE4.2
crc32instruction,__builtin_cpu_supportsruntime dispatch, and a byte-identical portable table fallback. The fallback is a correctness requirement, not just portability: a segment written on a host with hardware support must verify on one without, so both paths are pinned to the same reflected polynomial (0x82F63B78). ~152 MB/s → ~13.4 GB/s. No-msse4.2on the translation unit.Also splits
segment::Writer::PrepareintoBuild(checksum + record assembly) andReserve(index reservation).SSDTierrunsBuildoutsidemu_and onlyReserveunder it, so a write batch no longer blocks concurrent reads on the same drive for its whole CRC + copy time. Critical section 908 ms → 0.04 ms.2. Startup truncate guard (
d499c03)A segment whose head the scanner cannot parse silently swallows every subsequent write: the scanner stops at the first unreadable header, but
write_offsetcomes from the file size, so the writer appends past the junk. Next open, the scanner stops in the same place — everything written in between is gone, permanently.Nothing upstream catches this.
PeerSsdManager::DiscardLeftoverOnStartuponly wipes whenCapacity()reportsused > 0, and a scan that broke at offset 0 reportsused == 0— so it logs "no SSD leftover" and skips the wipe. It surfaces as a 0% SSD hit rate with no error anywhere.The record-format changes in this PR make a stale directory exactly this case, which is why the fix ships alongside them. It is kept as a separate commit so it can be reverted independently.
3. Direct I/O (
f45be75)The SSD tier is itself a cache, so buffered I/O gives it a second, unmanaged DRAM cache underneath: reads are served from the page cache at many times device bandwidth, the node reports DRAM it is actually consuming as free, and any measurement of drive behaviour is meaningless — a "1 drive vs 2 drives" comparison can show no difference simply because neither run touched a drive.
ssd.direct_io=true(UMBP_SSD_DIRECT_IO=1) bypasses it.O_DIRECT requires buffer address, file offset and length to all be alignment multiples, which the old layout satisfied for none of the three. Hence record layout v3, padded to a fixed
kRecordAlignof 4096:[header|key|pad][value|pad]. A fixed constant rather than the device's reported requirement, so a directory keeps its meaning when it moves between devices. Padding is unconditional, so one directory is readable with either setting — only the reader's buffer alignment differs.Supporting pieces:
AlignedBuffer(posix_memalign-backed, zeroes padding so it cannot leak heap contents to the device); a startup probe that round-trips one aligned block rather than trusting a capability bit, catching tmpfs/overlayfs and coarser-alignment devices, falling back to buffered with a warning rather than failing to come up; and a bounce path for unaligned read destinations — the exception rather than the rule, but required, because without it the failure is anEINVALthe per-key fallback would just repeat, surfacing as a silent 100% miss.Compatibility
kRecordVersion1 → 3. Old records fail the scanner's pre-existing version check and are dropped; this tier is a cache, so its contents are always re-fetchable. No multi-version read support is included, by design.Capacity is now charged in padded bytes — a 512 B value occupies 8192 B once prefix and value are each padded. Honest accounting of what the device gives up, but a 16× change for small values; worth knowing before this meets a small-KV workload.
ssd.segment_size_bytesmust now be a multiple of 4096, or the append cursor lands unaligned at segment roll-over.Validate()rejects it up front rather than failing at the first direct write.Two knobs come with direct I/O because they are needed to interpret its results:
ssd.verify_crcisolates checksum cost from storage cost (records written with it off carrykFlagNoCrcand stay readable by a reader with it on), andssd.tier_io_threadsfans the tier's CPU-bound phases over 4 workers by default, matching the DRAM tier'sread_threads_/write_threads_so a DRAM-vs-SSD comparison does not silently measure 4 threads against 1. Both defaults are behaviour-neutral.Testing
Full umbp
ctest: 38/41. The 3 failures are the pre-existingno active RDMA devicecontainer limitation, thrown inSetUp()before any tier code runs.test_segment_crc(new) pins the standard CRC-32C check value0xE3069283, which also catches an accidental revert to ISO-HDLC (check value0xCBF43926); verifies the selected path against an independent bitwise reference at every tail length mod 8 and at unaligned starts; checks streaming composition andBuild/Reservegeneration stamping.test_ssd_direct_io(new) covers layout invariants,AlignedBuffer, a buffered/direct round trip against both aligned and unaligned destinations (so both the direct and the bounce path), theverify_crc=falseround trip, thread-count invariance, and padded capacity accounting. Confirmed running with the probe genuinely active, not silently falling back to buffered.test_ssd_tiergains a case for the stale segment head. Verified it fails without the guard commit — the second key is unreadable after restart — so it pins the actual regression rather than passing vacuously.🤖 Generated with Claude Code