Skip to content

perf(umbp): hardware CRC-32C and direct I/O for the SSD tier - #526

Open
TianDi101 wants to merge 3 commits into
mainfrom
feat/umbp-ssd-crc-directio
Open

perf(umbp): hardware CRC-32C and direct I/O for the SSD tier#526
TianDi101 wants to merge 3 commits into
mainfrom
feat/umbp-ssd-crc-directio

Conversation

@TianDi101

Copy link
Copy Markdown
Collaborator

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_TIMING instrumentation or UMBP_SSD_DURABILITY knob is included here.

1. Hardware CRC-32C (9573db2)

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 read and write. On the SSD tier the checksum, not the drive, set the ceiling.

Replaced with CRC-32C via the SSE4.2 crc32 instruction, __builtin_cpu_supports runtime 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.2 on the translation unit.

Also splits segment::Writer::Prepare into Build (checksum + record assembly) and Reserve (index reservation). SSDTier runs Build outside mu_ and only Reserve under 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_offset comes 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::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. 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 kRecordAlign of 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 an EINVAL the per-key fallback would just repeat, surfacing as a silent 100% miss.

Compatibility

kRecordVersion 1 → 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_bytes must 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_crc isolates checksum cost from storage cost (records written with it off carry kFlagNoCrc and stay readable by a reader with it on), and ssd.tier_io_threads fans the tier's CPU-bound phases 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. Both defaults are behaviour-neutral.

Testing

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.

  • test_segment_crc (new) pins the standard CRC-32C check value 0xE3069283, which also catches an accidental revert to ISO-HDLC (check value 0xCBF43926); verifies the selected path against an independent bitwise reference at every tail length mod 8 and at unaligned starts; checks streaming composition and Build/Reserve generation 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), the verify_crc=false round trip, thread-count invariance, and padded capacity accounting. Confirmed running with the probe genuinely active, not silently falling back to buffered.
  • test_ssd_tier gains 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

TianDi101 and others added 3 commits August 5, 2026 08:05
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>
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