Skip to content

Latest commit

 

History

86 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

md-kmec

Build note: This is one component of the raidkm mdraid stack and is not meant to be built on its own. Please use mdraid-super to build the entire package — it assembles this repo together with the other components in the correct order.

Operators: docs/raidkm-field-manual.md is the task-ordered companion to this README — the feature catalogue, the layout maps, and every command for create / scrub / rebuild / grow / shrink in one place. This README stays the canonical record of design decisions, milestones and measurement provenance.

A Linux md personality implementing arbitrary k+m Reed-Solomon erasure coding, built as a fork of our optimized mdraid's raid5.c plus our ISA-L fork's EC primitives (GFNI when the CPU has it, table-lookup GF_REGION_MUL fallback otherwise).

Registered as a personality at level 71 under the name raidkm. All m use the ISA-L gf_gen_rs_matrix Reed-Solomon code: m≥3 calls ISA-L's ec_encode_data_* directly, while m=2 is computed with raid6's tuned SIMD (raid6_call) — which produces byte-identical parity to the ISA-L m=2 encode (see "Architectural commitments" below). Parity placement is selectable per array via --layout: rotating (the default — generalized left-symmetric, spreads parity and read traffic across all members, matching stock RAID6's own default placement), parity-last (dedicated parity on the tail m disks, which keeps the cheap offline add-a-parity grow), or declustered (narrow k+m groups scattered over a wider pool by a balanced permutation, with a distributed spare — so a single-disk rebuild is parallelised across the whole pool instead of bottlenecked on one replacement; see Declustered parity below). The parity-disk count m is set separately with --parity-count.

The earlier standalone implementation lives on the proto branch and the v0-proto tag. Benchmarks of that prototype against the raid5.c-derived line motivated switching to the fork-and-extend approach this branch takes.

Architectural commitments

These are load-bearing for the design and not up for negotiation:

  1. k+m semantics from the start, including m=2, with an on-disk format that stays reshape-compatible to arbitrary m. Parity is the ISA-L gf_gen_rs_matrix Reed-Solomon code in PARITY_N layout for every m. At m=2 that code's first two rows are exactly P=XOR and Q=Σ2ⁱ·Dᵢ over GF(2⁸)/0x11d — identical to raid6's P+Q — so we compute m=2 with raid6's tuned SIMD (raid6_call) for speed while writing byte-identical parity, keeping the m=2 image a valid prefix of the m≥3 ISA-L encoding (so "add a parity" stays incremental). raid6's hardcoded math alone is a dead end for m>2, which is why m≥3 uses ISA-L directly. Verified byte-for-byte: see the EC-verifier milestone below.

    Encode and decode are asymmetric. The m=2 encode above runs raid6's XOR + shift P+Q (raid6_call.gen_syndrome — AVX VPXOR for P, the shift/ mask GF(×2) for Q). Decode (degraded read, rebuild, degraded-write reconstruct) instead routes through one unified path for every m (ops_run_compute_km): build the survivors' decode matrix, invert it with gf_invert_matrix, and apply it with ISA-L's ec_encode_data_* — GFNI (ec_encode_data_avx512_gfni / avx2_gfni) when the CPU has it, else the scalar ec_encode_data_base table lookup. Decode is deliberately PSHUFB-free: it never uses raid6's *_recov (the inherited 2-failure path raidkm doesn't reach) nor ISA-L's PSHUFB kernels, avoiding the StreamScale patent surface. The two SIMD selections are independent: GFNI decode comes straight from isal_lib (gated on isal_have_*gfni()), so it needs no raid_isal.ko — that optional override only swaps the m=2 encode raid6_call to a GFNI P+Q and does not touch decode.

  2. Forked from raid5.c, not patched in place. raid_km.c is a copy + modify of raid5.c. Stock raid4/5/6 stay untouched in the kernel; we accept the maintenance cost of porting upstream raid5 fixes manually.

  3. m=2 AND m=3 validated before declaring any milestone done. m=2 because it's the most-tested case and lets us cross-check against stock raid6 behavior; m=3 because it's the first case that stock raid6 can't do at all.

Status

Milestone State
Prototype preserved at v0-proto tag / proto branch ✅ done (2026-05-23)
Master cleared, scaffolding committed ✅ done (2026-05-23)
Fork raid5.ckm/raid_km.c, symbols renamed, builds clean ✅ done (2026-05-23)
Loads as a personality at level 71, coexists with stock raid456 ✅ done (2026-05-23)
m=2 array activates natively at level 71, no shim ✅ done (2026-05-23)
Basic I/O works (writes + reads) ✅ done (2026-05-23) — 1.9 GB/s write, 7.9 GB/s read on brd
Resync / scrub path works ✅ done (2026-05-23) — completes cleanly in ~2.4 s for 2 GiB
Standard benchmark passes vs stock raid6 ✅ done (2026-05-23, re-measured 2026-05-29) — 1.34-2.35× at default, up to 1.43-2.28× tuned; re-measured on RHEL 10.2 across base / AVX2-GFNI / AVX-512-GFNI (2026-06-15): raidkm wins every workload — 1.35-2.43× scalar, up to 4.20× on AVX-512-GFNI (see table below)
Reliability soak: long fio randwrite+verify, disk-fail mid-I/O, scrub/repair ✅ done (2026-05-25) — ~230 GiB crc32c-verified across healthy/2-fail/3-fail; a member failed mid-fio survives with correct data; scrub mismatch_cnt=0; surfaced + fixed an nsrc<k WARN on mid-I/O failure
Reliability: hot-replace / rebuild onto a spare ✅ done (2026-05-25) — fail a member, mdadm --add a spare, recovery reconstructs (data via decode, parity via re-encode) and writes the rebuilt member; validated for data-disk, parity-disk, and rebuild-while-still-degraded — spare holds correct bytes, scrub mismatch_cnt=0
Write-intent bitmap (unclean-shutdown resync only the dirty bits) ✅ done (2026-05-25) — internal bitmap created/assembled on level 71; writes set dirty bits and endwrite clears them after settle; a write-while-degraded marks only the affected region dirty (3/128 chunks for an 8M write); --re-add does a bitmap-scoped recovery (17s vs 171s full rebuild, throttled) with correct data; a fresh-spare --add still does a full rebuild (not wrongly scoped). Inherited from raid5.c — no kernel changes needed
PPL (partial parity log) — closes the raid5/6 write hole ✅ done (2026-05-25) — opt-in, off by default (enable with mdadm --create … --consistency-policy=ppl). Extended raid5-only PPL to arbitrary m: logs all m partial parities (raid5 logs only the single XOR P). RMW copies the m parity pages (prexor already leaves them = partial parity); RCW encodes the not-overwritten data (raid6 at m=2, ISA-L at m>2); recovery rebuilds every parity as P_j = PP_j XOR encode_j(modified). Validated: 2 power-loss crash tests (virsh destroy mid-fio) replay the log on reassembly, mismatch_count=0, post-recovery scrub clean. Cost (opt-in): −43% to −72% on brd — the inherent serialized FUA log write, exaggerated by RAM-speed backing; arrays without --consistency-policy=ppl are unaffected. Mutually exclusive with the write-intent bitmap
k+m via ISA-L (replace raid6_call for m ≥ 3) ✅ done (2026-05-24)
Full-stripe write works for m=3 ✅ done (2026-05-24)
Small RMW writes for m=3 ✅ done (2026-05-24) — ec_encode_data_update_* RMW
m=2 byte-identical to ISA-L via raid6_call fast path (reshape-compatible, full perf) ✅ done (2026-05-24)
EC correctness verified (encode == ISA-L + every erasure reconstructs) ✅ done (2026-05-24) — verifier 18/18 at m=2 (k=2..10, full + RMW), m=3 spot-checks
m-way scrub / resync / repair for m ≥ 3 ✅ done (2026-05-24) — synchronous ISA-L re-encode + compare; detect→repair verified on GFNI + base
Degraded reads / recovery for m ≥ 2 (m-way decode via gf_invert_matrix) ✅ done (2026-05-24) — survives up to m failures on read; verified 1/2/3-fail on GFNI + base
mdadm --create / --assemble for level 71 (persistent v1.2 superblock) ✅ done (2026-05-25) — patched mdadm 4.4; data round-trips stop→assemble for m=2/3/4; degraded assemble works
mdadm --grow --add-parity (alias: --add) to add a parity disk, m → m+1 (PARITY_N: offline grow-via-resync; rotating: online COW reshape) ✅ done — PARITY_N (2026-05-25): offline recreate, data + UUID preserved across m=2→3 and m=3→4; no data movement (parity appended, existing data stays put). Rotating — now an online, journaled COW reshape (2026-06-09; merged to master 2026-06-11): in-kernel, in-place (constant data_offset, no stripe-cache), EC-correct, no backup file, and crash-safe — a power loss mid-reshape is recovered by a plain mdadm --assemble replaying the in-kernel journal. Each band is staged out-of-place before its home is overwritten, so the read/write location-aliasing race that sank the earlier in-place attempt (withdrawn 2026-06-01) is structurally gone. The pre-COW offline windowed relocation is retained as a fallback for kernels without the COW engine (MDADM_RAIDKM_OFFLINE_ADDPARITY). Validated whole-array m=2→3 (base) + m=3→4 Cauchy (GFNI) — data + scrub + new-m-degraded-read oracle — plus a true power-loss crash; reshape crash/fault suite 114/0 on base+GFNI. See the --add-parity section below
mdadm --grow --add-data to add a data disk (online reshape, capacity grow at fixed m) ✅ done (2026-05-26) — drives the inherited kernel online reshape (delta_disks, fixed max_degraded); relocation rides the layout-aware sector map, no backup file needed (a grow has writepos < readpos). The data-disk count k changes, so the ISA-L EC matrix/tables are rebuilt for the new k in raid5_start_reshape; the old-k set is kept as prev_ec_* and selected per stripe by k (raidkm_a_matrix/raidkm_g_*) for the duration of the reshape, since I/O to pre- vs post-reshape_position stripes uses both geometries (freed at finish_reshape; rebuilt at mid-reshape assembly). Both layouts. mdadm freezes the array before the spare-add (else the raid_disks write races md_check_recoveryEBUSY). Validated by degraded-read-after-grow (not just scrub, which masks a stale-table bug): PARITY_N+rotating × m=2/3/4 max-degraded, plus m=3 grown to k=6 — all reconstruct correctly; GFNI-checked
Rotating parity layout (balance disk usage; parity-last is dedicated-parity) ✅ done (2026-05-26) — selected via --layout=rotating (generalized left-symmetric: the m-slot parity block rotates one disk per stripe so parity — and normal-read traffic — spread across all members instead of the tail m). One slot mapping serves both layouts (pd_idx is the only layout-specific value; parity-last is the pd_idx==k case), so encode/decode/scrub/PPL are layout-agnostic. Layout packed into the superblock layout field (low byte = m, bit 0x100 = rotating). Rotating later became the create default (2026-06-01); the parity-last placement is bit-for-bit unchanged. Validated: m=2/3/4 create/read/scrub=0, degraded read+write (2- and 3-disk loss), stop→assemble persistence, PPL+rotating partial-write scrub=0, rotation confirmed by raw-disk compare. Add-parity is supported on both layouts but at different cost: PARITY_N appends a parity disk cheaply (offline grow-via-resync, no data movement), while rotating must relocate every block, so its add-parity drives a full online COW reshape (journaled, out-of-place per band, no backup file; an earlier in-place online reshape was withdrawn for a location-aliasing race) — see the --add-parity row above
Degraded write + degraded-array scrub (any m ≥ 2, up to m failures) ✅ done (2026-05-25) — reconstruct failed data from k survivors, re-encode all parity, write the surviving members; data correct + no deadlock across m=2/3/4/5 × 1–m failures (data-only / parity-only / mixed / max-degraded)
GFNI cross-validation of degraded write + recovery ✅ done (2026-05-25) — degraded-write matrix + hot-replace rebuilds repeated on an i5-1340P (GFNI): 10/10 pass, exercising the ec_encode_data_avx2_gfni path. The KVM testbed has no GFNI, so this is the only coverage of the GFNI EC variants under the new write/recovery scheduling
Device-mapper: drive raidkm via the kernel dm-raid target (dmsetup) ✅ done (2026-06-05) — level 71 is reachable through the in-tree dm-raid target with no new dm target (dmsetup create … raid raidkm <chunk> parity_count <m> …); m + rotating ride in the dm table, a FEATURE_FLAG_RAIDKM superblock bit keeps stock dm-raid from touching a raidkm SB. Phase 1 (create + I/O + degraded + scrub + reassembly) and Phase 2 (rebuild via reload + rebuild <idx>) validated 21/21 base + 51/51 GFNI, m=2..6. Reshape via dm is gated off (a hand-driven dmsetup grow corrupts — needs LVM's data-offset positioning). The dm-raid.c changes live in the mdraid fork. See notes/dm-raid-design.md
LVM-managed raidkm (lvcreate --type raidkm) ✅ done (2026-06-05) — the lvm2 raidkm fork provisions level-71 LVs via two segtypes raidkm (rotating) / raidkm_n (parity-last) carrying parity_count (m). Validated base + GFNI, m=2/3/4: create/activate/I/O/reassembly/degraded; lvconvert --repair (raidkm-aware leg replacement + rebuild); and dmeventd monitoring + auto-repair (level-agnostic plugin, no code change). Reshape via dm/LVM is out of scope (the data-offset out-of-place reshape doesn't fit raidkm — mdadm-only); the kernel reshape gate stays on. See notes/dm-raid-design.md
Checksum-driven self-healing — reconstruct silently-corrupt blocks from parity, up to m per stripe ✅ done (2026-07-02) — driven by a per-block integrity signal (md-kmec's own native checksums, next row, or a stacked dm-integrity; T10-PI passthrough next), md-kmec turns an integrity-flagged read error into an m-erasure reconstruction: the corrupt block is rebuilt from parity and rewritten, on both the read path and the m-way scrub, with mixed data+parity corruption healed in a single repair pass (durable R5_IntegrityHeal marker). A read-only healed_blocks sysfs counter reports repairs. Validated to m=8 — heals 8 simultaneous silent corruptions in one stripe (data-only, parity-only, and mixed 4+4), deterministically, beyond RAID-Z3's 3. The integrity layer supplies the detection; md-kmec supplies the reconstruction. See tools/raidkm-test-selfheal.sh (runs on native checksums with NATIVE=1, or on dm-integrity)
Native per-block checksums — built-in per-4K CRC-32C integrity, no stacking required ✅ done (2026-07-14) — mdadm --create … --checksum=crc32c (bare --checksum also works; --integrity=crc32c is a retained alias) gives every 4 KiB block a CRC-32C that raidkm computes, stores, and verifies itself. CRCs persist in a reserved region at each member's tail (~0.1% of capacity, carved at create; SB layout bit 0x200) in self-checking pages (per-page CRC + generation trailer, so a rotted region page is detected and dropped, never trusted), served through a bounded demand-paged cache, auto-sized at array start to cover every member's whole CRC region (~1 page per 4 MiB of member capacity; clamped to ~1.6% of RAM with a logged hint when a huge array is clamped below full coverage — undersizing costs region-page faults, never correctness; override via raidkm_csum_cache_pages). Reads verify inline in the bio completion (lock-free expected-CRC lookup + crc32c, the dm-integrity inline-mode shape) including a verified chunk-aligned read bypass; stores land at write issue; a fast-path mismatch is never trusted — it is rechecked through the stripe cache before feeding the self-heal above. Real-NVMe validated (8× local SSD, m=2, fio direct): reads and writes at 96–101% of the no-checksum baseline (random read 100.0%), ahead of dm-integrity bitmap on all four workloads and of journal on writes (2.1–2.3×) and random read (1.26×), tying it on sequential read — with 0 false mismatches. Crash semantics: CRCs are recomputed over the resync window after an unclean shutdown (same class as dm-integrity bitmap; journal's crash-atomic tags are the stronger guarantee its ~2× write cost buys). Concurrency-hardened by a three-lens adversarial review (verify-fence ordering, workqueue isolation, rdev pinning). Composes with declustered parity (2026-07-18): the CRC region stacks after the rkdcl block in the member tail, CRCs are keyed by the physical pool disk (so a spare-redirected read still verifies against the disk it hit), and the copy-from-spare rebalance migrates each block's CRC with the bytes — gate tools/raidkm-test-declustered-csum.sh. Design + benchmark: notes/native-checksum-read-redesign-2026-07-14.md; gates: tools/raidkm-test-csum-thrash.sh (cache-eviction round-trip) and the self-heal suite in NATIVE=1 mode. Real-NVMe re-gate (2026-07-15) on 4K-logical local SSD under KASAN + lockdep: functional 12/12, csum-thrash, self-heal 60/60, randrw churn 0 WARNs, 0 splats — the re-gate found and fixed a skip_copy×checksum read/write invariant WARN_ON (need_this_block now defers a read overlapping a draining skip_copy write)
mdadm --grow --raid-devices=<N-1> to REMOVE a data disk (shrink-data, online, m fixed) ✅ done (2026-07-30) — the inverse of --add-data, via the same journaled per-band COW engine walked backwards (the narrower geometry packs data deeper per disk, so the walk starts at the end; every home write lands strictly below the still-live old rows — no backup file, no data_offset shift). One disk per invocation (v1). Array-size-first: shrink the filesystem, clamp with --grow --array-size=<kb> (mdadm computes and prints the exact value on refusal), then --grow --raid-devices=<N-1>; the freed member becomes a spare (remove + --zero-superblock before reuse). Crash-safe: a mid-shrink stop/power-loss recovers by plain --assemble (the journal resumes the descending walk). Native-checksum arrays included (band verify-src + CRC re-key). Both layouts, m=2/3 validated: data byte-exact, scrub clean, decode oracle at m, concurrent fio randwrite+verify across the moving frontier, crash/resume ×2. The declustered analogue — pool shrink by one group (--grow --raid-devices=<N-g>) and spare-count increase — shipped 2026-07-31 via the same backward engine (gates: tools/raidkm-test-declustered-shrink*.sh). Gates: tools/raidkm-test-shrink{,-crash}.sh
Declustered parity — wide-pool layout with distributed spare (--layout=declustered) ✅ done (2026-07-16) — narrow k+m groups scattered over an N-disk pool by a seeded, rotation-expanded balanced permutation (clean-room, dRAID-lineage combinatorics — Holland & Gibson design theory, no OpenZFS/CDDL code), with distributed spare columns instead of a dedicated hot spare. Capacity balance is exact by construction and the rebuild read/write load is rotation-symmetric, so a failed disk's reconstruction spreads across every survivor. Headline: rebuilding a failed 16 GB member on an 80-disk pool (g=13) took 44.9 s vs 785.1 s for a classic 78+2 array — 17.5× (it also beats a narrow 11+2 classic, because the distributed spare removes the single-writer bottleneck). Geometry via --group-width=g [--spare-columns=s] [--dcl-nbase=n] [--dcl-seed=x]; the permutation seed + geometry live in a per-member on-disk rkdcl metadata block (4 KiB tail reserve, crc + generation), the kernel regenerates the identical permutation from the seed. Constraint C1 (N−s) mod g == 0 (mdadm validates + suggests a legal s). Declustering is m-agnostic — validated m=2..5 on real NVMe (incl. the single-group-row ngroups=1 edge). Native per-block checksums compose (see the next row), and online pool expansion (mdadm --grow --raid-devices=N') widens the pool via a journaled crash-recoverable COW reshape (m=2/3 validated: data intact, scrub clean, degraded-read EC-correct, crash/power-loss/torn-write recovered); the per-group geometry reshapes (--add-parity / --add-data / --spare-columns=s') run online too, serving the un-migrated region with old-geometry stripes (native-checksum arrays included); capacity-shrinking reshapes — pool shrink by one group (--grow --raid-devices=<N-g>) and spare-count increase — run online via the same engine walked backwards, gated array-size-first; v1 still excludes a file-backed bitmap (refused, not run wrong). See Declustered parity below
Declustered population + rebalance — rebuild into / migrate out of the distributed spare ✅ done (2026-07-17) — when a member fails, population reconstructs its content into the distributed spare columns as a raidkm-owned sync action (echo <idx> > /sys/block/mdN/md/rk_dcl_populate, or automatic with rk_dcl_auto=1), advancing a crash-safe journaled prefix mark (rkdcl v2, FUA+PREFLUSH every 16 MiB). Sequential multi-assignment (up to s failed disks, one populating at a time) uses chained redirects — a spare column can rotate onto another failed disk, so the slot→disk map resolves through the active assignments; the on-disk journal is adaptive v2/v3 (a published v2 module fails closed rather than silently dropping an assignment). Adding a replacement rebalances by copy-from-spare (16-worker parallel copy straight from the already-populated spare — no GF decode, array never degraded during it; per-band quiesce + offset-split, strict-journaled per band) instead of a decode rebuild, falling back to the validated decode leg on any persistent copy fault. With native checksums enabled the copy migrates each block's CRC from the spare disk's key to the replacement's alongside the bytes, so integrity is preserved end-to-end (a torn/rotted copy is caught on first read and healed by decode). Power-loss crash matrices (dm-flakey) green for population, second-population, and mid-copy resume. Hardened by two adversarial multi-agent reviews (16 findings fixed: journal quorum, arm-publication barriers, auto-arm rescan, a copy-abort progress-contract data-loss bug, wedge-to-decode fallbacks). Validated under KASAN + on stock-RHEL real NVMe. See Declustered parity below

How level 71 is integrated into raid5.c

raid_km registers at level 71 but reuses raid5.c's existing m=2 code paths via a per-conf effective_level field set to 6 in setup_conf. Internal "do raid6 math" checks (switch (conf->level) cases, conf->level == 6 branches) consult conf->effective_level instead, so the verbatim raid5.c logic fires for raid_km without mutating mddev->level (which md core treats as invariant after md_run). Two further fixes were needed to make it land:

  • effective_level set order: must be assigned before raid5_alloc_percpu, because the CPUHP callback path (raid5_alloc_percpu → cpuhp_state_add_instance → raid456_cpu_up_prepare → alloc_scratch_buffer) reads it to decide whether to allocate the per-CPU spare_page used for P+Q syndrome validation. If set later, spare_page stays NULL and async_pq.c hits BUG_ON during resync.
  • CPU-hotplug slot: raid5_alloc_percpu and free_scratch_buffer originally referenced the fixed CPUHP_MD_RAID5_PREPARE enum, which stock raid456 already reserves. raid_km asks for a dynamic slot via CPUHP_BP_PREPARE_DYN (stored in raid_km_cpuhp_state) and both call sites use that instead.

Benchmark — raidkm vs stock raid6

Out of the box — stock raid6 at its default group_thread_cnt=0 vs md-kmec raidkm at its defaults — tools/raidkm-standard-benchmark.sh --runs=3, 16 × brd ramdisks, k=14 m=2, 64 KiB chunk, both --assume-clean, on a GCP c3-standard-22 (22 vCPU, RHEL 10.2 6.12.0-211.16.1.el10_2). raidkm auto-defaults group_thread_cnt to nproc/2 (= 11 here) and now enables zero-copy writes (skip_copy) by default; stock ships both off (IOPS, mean of 3 runs; Test 6 = post-run integrity check, mismatch_cnt=0):

Test stock raid6 (gtc=0) md-kmec raidkm (default) speedup
1 Random 4K Write (RMW worst case) 54,266 375,269 6.92×
2 Database Mixed 75/25 8K 106,072 868,263 8.19×
3 High Concurrency 70/30 4K (16 j) 177,730 1,210,715 6.81×
4 OLTP 70/30 16K 52,574 396,083 7.53×
5 Partial Stripe Write 8K 31,256 230,669 7.38×

The out-of-box gap (~7-8× on this wide array) is dominated by md-kmec's worker-group auto-default — stock raid6's RMW path is serialized at gtc=0, while raidkm parallelizes stripe handling across nproc/2 worker threads. This is a default-vs-default comparison; at matched gtc the structural-only edge is much smaller (the win is the on-by-default tuning, see the SIMD table and the vCPU-scaling note below).

Zero-copy full-stripe writes (skip_copy, default on)

md-kmec defaults skip_copy on (in raid5_set_limits): for a full-page-aligned write the stripe-cache page is aliased directly to the incoming bio page instead of being memcpy'd in through the biodrain step — and that copy, not the erasure-coding (EC is ~0.6 % of write-path CPU), is the dominant cost of a full-stripe write. The only requirement is stable pages, which is free for the O_DIRECT workloads this fork targets; override per-array via the skip_copy sysfs attribute.

With native checksums on, a chunk-aligned read is re-driven through the stripe cache for verification (retry_aligned_read). If it lands on a block whose skip_copy write is still draining — the stripe page is aliased to the write bio and intentionally left non-UPTODATE until handle_stripe_clean_event() hands it back to orig_page — the read is deferred in need_this_block() and served from disk once the write completes (correct read-after-write). Serving it inline would set R5_UPTODATE on a R5_SkipCopy block and trip that invariant's WARN_ON; the deferral was added 2026-07-15 after the real-NVMe re-gate (below) caught the race under randrw churn.

Full-stripe sequential DIO write (16-disk wide array above, bs=896k = one full stripe, 3 reps):

skip_copy throughput
1 (md-kmec default) 13.33 GiB/s
0 9.56 GiB/s

+39 %, matching the +36 % measured on stock raid6 with skip_copy forced on. The win is confined to full-stripe writes; on small/partial RMW writes the tiny copy is dwarfed by read-modify-write amplification, so skip_copy is neutral there — 8 KiB partial-stripe random write, 4 reps each:

skip_copy IOPS per rep
1 210k / 214k / 214k / 215k
0 210k / 210k / 212k / 209k

(no regression — within noise, marginally ahead). Note the standard-benchmark suite above is entirely small random / RMW writes, so it does not exercise the regime skip_copy helps; the full-stripe win shows on the large-sequential-write path.

raid5_unplug device_lock removal (random-write scaling)

raidkm inherits raid5.c's plug/unplug path, and raid5_unplug() released every plugged stripe under the global conf->device_lock — one acquisition per flush, the dominant CPU cost under many concurrent submitters. md-kmec now routes those releases through the lockless released_stripes llist (raid5_release_stripe(), the same path used when unplugged), coalescing all submitters' releases into batched drains by raid5d + the worker threads. No new lock or invariant — functional suite 12/12, scrub mismatch_cnt=0, and the change is mirrored to the perf-tree raid5.c (an upstream-ready md/raid5 fix that lifts stock raid6 identically).

Throughput proof on raid6 14+2 — the change is in shared raid5.c, so these numbers measure it directly on the mirrored perf tree; raidkm runs the identical raid5_unplug path — 64 KiB chunk, group_thread_cnt=16, fio 4 KiB random write numjobs=24 iodepth=32, base (without the change) vs patched, 3 interleaved rounds, on null_blk (device-unlimited, so the md layer is the bottleneck), GCP n2-standard-32 (32 vCPU, 2 NUMA):

IOPS (4K randwrite) CPU busy
base 221,067 92 %
patched 276,478 84 %

+25 % throughput at lower CPU; perf shows raid5_unplug → _raw_spin_unlock_irq 28 % of all CPU → 0. Neutral at the stock default group_thread_cnt=0 (single-raid5d-bound): 0.98×, no regression.

On real NVMe (16 × GCP local SSD): 4 KiB random write is device-capped at ~199 K (≈ the lock ceiling), so IOPS ties and the win is CPU efficiency — at matched group_thread_cnt, same ~199 K IOPS at 54 % busy vs stock raid6's 69 % (+28 % IOPS per %CPU). The faster the storage, the more the lock dominates (28 % of CPU on null_blk vs 14 % on this NVMe), so the throughput win grows on storage fast enough to clear the single-lock ceiling — the very-wide-array (k+m ≥ 80) regime this fork targets.

Across the SIMD spectrum (6-disk, three boxes)

raidkm m=2 vs stock raid6 with the canonical tools/raidkm-standard-benchmark.sh --runs=3 (6-workload OLTP/IOPS suite; drops page cache + dentries before every test; both arrays created --assume-clean so neither resyncs during the run), 6 brd ramdisks, k=4 m=2, 512 KiB chunk, on RHEL 10.2 (kernel 6.12.0-211.22.1.el10_2). Re-measured 2026-06-15 across the full SIMD spectrum to separate the structural win from the ISA-L GFNI encode (IOPS, mean of 3 runs; Test 6 = post-run integrity check, mismatch_cnt=0 everywhere):

Test base / no-GFNI
(AMD Ryzen 5800X)
AVX2-GFNI
(Intel i5-1340P)
AVX-512-GFNI
(Xeon 8481C / GCP c3-standard-8, 8 vCPU)
1 Random 4K Write 239,211 vs 124,327 (1.92×) 107,728 vs 46,615 (2.31×) 305,853 vs 72,767 (4.20×)
2 DB Mixed 8K (75/25) 420,982 vs 275,658 (1.53×) 182,964 vs 96,838 (1.89×) 504,563 vs 157,899 (3.20×)
3 High Concurrency 4K rw 555,725 vs 410,337 (1.35×) 219,223 vs 135,716 (1.62×) 818,197 vs 220,291 (3.71×)
4 OLTP 16K rw 222,370 vs 124,760 (1.78×) 88,546 vs 42,677 (2.07×) 266,346 vs 73,455 (3.63×)
5 Partial Stripe Write 8K 179,735 vs 73,994 (2.43×) 59,135 vs 24,053 (2.46×) 159,960 vs 43,837 (3.65×)

(Each cell is raidkm vs stock raid6 IOPS and the speedup.) raidkm wins every workload at every tier, at ~2-4× lower latency. The win is structural: the forked raid5.c carries our post-fork mdraid optimizations — worker-groups auto-default, STRIPE_ON_INACTIVE_LIST lock-skip, and a faster write/RMW/ partial-stripe path.

⚠️ The ratio scales with vCPU/core count — it is not a fixed per-machine constant. raidkm's worker groups parallelize stripe handling across cores (total worker threads auto-default to nproc/2; see Tuning to change it), so raidkm throughput rises with cores, while stock raid6's RMW path is largely serialized and barely scales. Measured on the same main build, same GCP c3 / Xeon 8481C, varying only the vCPU count (Random-4K-Write, 2026-06-19):

instance raidkm IOPS stock raid6 IOPS ratio
c3-standard-4 (4 vCPU) 178,934 72,041 2.48×
c3-standard-8 (8 vCPU) 324,958 83,852 3.88×

So the three columns above differ as much by core count as by SIMD tier (the Xeon 8481C column was a c3-standard-8 / 8 vCPU box). At m=2 parity is the raid6_call P+Q fast path, not the ISA-L GFNI encoder — GFNI does not change the m=2 numbers (verified 2026-06-19: loading the GFNI raid6_call override moved m=2 IOPS <1%; the 8-vCPU column reproduces with GFNI off). GFNI's encode advantage shows at m ≥ 3. raidkm keeps scaling with cores; stock raid6 does not.

Absolute IOPS are not comparable across the three machines (different CPUs, core counts, RAM/ramdisk sizes). Reproduce the stock raid6 column by xzcating /lib/modules/$(uname -r)/kernel/drivers/md/raid456.ko.xz into a writable file and insmoding it instead of raid_km.ko.

Rebuild / resync speed

raidkm also rebuilds a failed disk substantially faster than stock raid6, because its resync path fans multiple stripes per sync_request instead of walking them one stripe-window at a time. Single-disk recovery, 6 × brd, k=4 m=2, 3 GiB/disk, on a GCP c3-standard-8 (8 vCPU, Xeon 8481C), with the global resync governor (/proc/sys/dev/raid/speed_limit_max) raised so neither side is throttled:

group_thread_cnt stock raid6 raidkm m=2
0 (stock default) ~200 MB/s 1178 MB/s (5.9×)
4 (matched) ~585 MB/s 1178 MB/s (2.0×)

Two things to read here. raidkm's rebuild rate is independent of group_thread_cnt (1178 MB/s at both 0 and 4) — the parallelism is in the sync path itself, not the worker pool; stock raid6's recovery is serialized at the default gtc=0 and only speeds up once worker groups are enabled. So the honest gap is ~2× apples-to-apples (matched gtc=4) and ~6× out of the box (stock ships worker groups off, raidkm's parallel sync is always on). Rebuild is a streaming, fully-parallelizable scan, so the gap is wider than the random-I/O table above. (brd is RAM-backed and compute-bound; on real disks the rebuild is capped by disk write bandwidth, so the gap narrows — the full win shows on fast NVMe or when the rebuild is CPU/EC-bound.)

On a device-bound array the gap closes. An independent evaluation over NVMe-oF with QLC namespaces (k=8 m=2, 128 KiB chunk, 1 TiB of each member) measured raidkm and stock raid6 at parity at every matched worker count: 535 vs 545 MiB/s at group_thread_cnt 32 with default block-layer settings, 615 vs 611 MiB/s tuned. There the rebuild is bound by its I/O pattern, not by CPU: both engines read survivors and write the spare in ~4 KiB stripe units (see Flash with a large indirection unit). Treat the 2× / 6× figures above as the CPU-bound ceiling, not as a promise for disk-bound arrays.

A three-way rebuild comparison on real NVMe (16 × local SSD, RHEL 10.2, raidkm-standard-benchmark.sh --rebuild-victim, 16 GiB member) — declustered vs classic on the current build, vs classic on the pre-declustered build (the parent of the first declustered commit, to isolate any personality overhead the declustered code adds to the classic path):

pool declustered populate classic recover (current) classic recover (pre-dcl) speedup
N=14 g=6 (4+2) 27.9 s 57.1 s 56.8 s 2.05×
N=80 g=13 (11+2) 48.3 s 761.5 s 777.5 s 15.8×

Two readings. Declustering wins the rebuild — 15.8× at N=80 (the classic 78+2 recover is single-writer-bound at ~21 MB/s; the distributed spare fans reconstruction across the whole pool). And the declustered code adds no overhead to the classic path — current-classic and pre-dcl-classic recover in the same time at both scales. Steady-state fio (6-workload suite, same run) shows no classic-path regression either.

Worker-thread tuning detail (single box, RHEL 10.1)

Earlier single-box measurement on the AMD Ryzen 5800X VM (md-kmec-rhel10, kernel 6.12.0-124.8.1.el10_1, --runs=3 --runtime=20) isolating the worker_thread_cnt knob: the auto-default wtc=2 (4-core single-NUMA box) vs the hand-tuned wtc=4 (see the Tuning section). Stock raid6 measured against the same VM/ramdisks/kernel.

Test raidkm Stock raid6
wtc=2 default (vs Stock) wtc=4 (vs Stock)
1 Random 4K Write 259,156 ± 5.2% (2.00×) 274,650 ± 0.9% (2.12×) 129,367 ± 4.3%
2 DB Mixed 8K (75/25) 427,510 ± 0.6% (1.50×) 467,748 ± 0.5% (1.64×) 285,811 ± 2.3%
3 High Concurrency 4K rw 568,588 ± 1.0% (1.34×) 606,627 ± 0.8% (1.43×) 423,035 ± 1.4%
4 OLTP 16K rw 239,478 ± 2.5% (1.85×) 242,968 ± 0.7% (1.88×) 129,588 ± 0.9%
5 Partial Stripe Write 8K 180,554 ± 1.1% (2.35×) 175,580 ± 1.0% (2.28×) 76,990 ± 0.4%

raidkm is 1.34-2.35× faster than stock raid6 at m=2 on every workload at the auto-default, and 1.43-2.28× with worker_thread_cnt raised to 4 (which lifts the floor on concurrent/mixed-write tests but slightly trades off Test 5). The win comes because the raid5.c we forked carries our post-fork mdraid optimizations (worker-groups auto-default, STRIPE_ON_INACTIVE_LIST lock-skip, etc.). The m=2 parity here is computed by the raid6_call SIMD fast path; after briefly unifying m=2 onto ISA-L (which regressed 30-50% on no-GFNI hardware) the fast path was restored. Reproduce stock raid6 numbers by xzcating /lib/modules/$(uname -r)/kernel/drivers/md/raid456.ko.xz into a writable file and insmoding it instead of raid_km.

Benchmark — native checksums vs no checksum vs dm-integrity (real NVMe)

Cost of the built-in per-4K CRC-32C integrity (--checksum=crc32c), measured 2026-07-14 on real hardware: GCP n2-standard-32 (32 vCPU, AVX-512), 8 × 375 G local NVMe SSD, RHEL 10.2 (6.12.0-211.16.1.el10). raidkm m=2 rotating (6 data + 2 parity), 64 KiB chunk, 8 GiB/member, --assume-clean + full-stripe prewrite; fio --direct=1 --iodepth=32, 30 s per point (seq = bs=1M, rand = bs=4k; seq read 4 jobs, rand read 16 jobs, rand write 4 jobs); CRC region cache sized to cover the working set (20480 pages ≈ 80 MiB). The dm-integrity columns run the same raidkm array (checksums off) over 8 × integritysetup crc32c members — journal is its crash-atomic default, bitmap its fast mode. Percentages are relative to the no-checksum baseline:

Workload no checksum native checksum dm-integrity journal dm-integrity bitmap
Sequential write (MB/s) 2245 2264 (101%) 1088 (48%) 2230 (99%)
Random write (K IOPS) 97.2 93.2 (96%) 40.8 (42%) 78.5 (81%)
Sequential read (MB/s) 5626 5599 (99.5%) 5624 (100%) 5014 (89%)
Random read (K IOPS) 1236.2 1235.9 (100.0%) 978.0 (79%) 934.4 (76%)

Verified integrity at effectively no cost: reads and writes run at 96–101% of the raw array — ahead of dm-integrity bitmap on all four workloads and of journal on writes (2.1–2.3×) and random read (1.26×), tying it on sequential read (0.4% apart, inside run-to-run variance). Zero false checksum mismatches across every run. The read side is the 2026-07-14 read-first redesign: verification runs inline in the bio completion (lock-free tag lookup + crc32c, no thread handoff) with a verified chunk-aligned read bypass — before it, random read sat at 37% of baseline. Fair-comparison note: dm-integrity journal is crash-atomic (tags+data survive power loss torn-write-free), a stronger guarantee than native or bitmap, both of which recompute checksums over the resync window after an unclean shutdown — that guarantee is what journal's ~2× write cost buys. Design + full write-up: notes/native-checksum-read-redesign-2026-07-14.md.

Cost of --grow --add-data (online capacity grow)

Same VM/ramdisks, m=2, 64 KiB chunk. A grow has two costs: a one-time restripe, and (the important question) any lasting penalty afterwards.

One-time reshape. Adding a data disk relocates every block, so the cost is a full online read+rewrite of the array — inherently O(data). Growing a filled k=4→5 array (≈2.0 GiB of data) took 2.5 s ≈ 825 MiB/s on brd. That is a RAM-speed upper bound: on real disks the reshape is bounded by disk bandwidth and throttled by sync_speed_{min,max}, exactly like any md reshape. The array stays readable/writable throughout, and no backup file is needed (a grow has writepos < readpos).

No lasting cost. A grown array performs the same as one created at the final geometry — the reshape leaves no scar. Steady-state IOPS for k=5 m=2 (N=7), natively created vs grown from k=4 (--runs=2 --runtime=15):

Test native k=5 grown k=4→5 grown / native
1 Random 4K Write 212,077 229,838 1.08*
2 DB Mixed 8K (75/25) 377,679 379,835 1.01
3 High Concurrency 4K rw 535,525 542,583 1.01
4 OLTP 16K rw 196,346 191,542 0.98
5 Partial Stripe Write 8K 138,016 143,182 1.04

The two are statistically indistinguishable (*Test 1's native run had cv≈8%, so 1.08 is run-to-run noise; the rest are within ±4%). So the only cost of --add-data is the one-time reshape; steady-state is identical to native.

Tuning

Deployment checklist

Ordered by impact. Most of the md side is already the default — the wins are geometry and layout decisions made before the array carries data. Each item is expanded in the sections that follow.

Decide once, at array-create time (a reshape is the only later fix):

  1. Choose k so k × chunk is a power of two — 256K, 512K, 1M. Large writes are almost always a power of two, so a row is only ever written whole if the row is one too. A width like k=5 @ 64K (320K) or k=14 @ 64K (896K) leaves a partial row at the tail of every write, at every chunk size, with no tuning available.
  2. Prefer declustered parity on wide pools — it decouples pool width from row width, so an 80-disk pool keeps a small, easily-filled row instead of a ~5 MiB one, and rebuilds far faster.
  3. Keep the 64K chunk default unless deliberately trading for a specific row width — or the members are flash with a large indirection unit: then make the chunk a power-of-two multiple of the largest unit you expect to deploy (128K for 16–64K units; see Flash with a large indirection unit).

Storage layout:

  1. Put the filesystem journal on a separate device — the single largest win here (see Keep the filesystem journal off the array). This is the filesystem's own journal (ext4/jbd2), not md's --write-journal.
  2. Start the partition or LV on a row boundary — nothing detects a violation at runtime; it silently phase-shifts every allocation.
  3. Keep other small, barriered write streams off the array — same mechanism as the journal.

Filesystem geometry:

  1. Set stride/stripe_width to match the array, or let mkfs.ext4 derive them from optimal_io_size. Verify with dumpe2fs -h <dev> | grep RAID.
  2. After a grow that changes k, refresh them with tune2fsmdadm --grow prints the exact command.

md tunables — verify, don't tune:

  1. skip_copy and worker groups are already on by default. On many-core hosts, raising worker_thread_cnt toward nproc may help concurrent writes — measure rather than assume.
  2. stripe_cache_size (default fine) and preread_bypass_threshold (irrelevant to full-row writes) are not worth sweeping.

One-line version: get k, the journal, and the partition offset right at build time; everything else is already the default or automatic.

Flash with a large indirection unit (QLC)

High-capacity QLC drives map logical blocks in units of 16 KiB, 32 KiB or 64 KiB instead of 4 KiB — the indirection unit (IU). A write smaller than the unit, or one that straddles a unit boundary, makes the drive read the rest of the unit and rewrite all of it: extra flash wear, extra latency. Reads below the unit cost only performance. So on these drives the number that matters is the request size at the member devices, after md has split and the block layer has merged the I/O.

Measured on a calibrated rig (tools/raidkm-bench-iosize.sh, k=8 m=2, 128 KiB chunk, 1 MiB sequential I/O; the rig reproduces an NVMe-oF QLC array's table):

state request size at the members
healthy and degraded full-row writes, m=2 (classic or declustered) ~123–128 KiB
full-row writes, m ≥ 3 ~5 KiB — full-row batching is off above m=2
degraded reads (classic / declustered) ~5 KiB / ~6 KiB
rebuild onto a spare: survivor reads / spare writes ~5 KiB / ~7 KiB
declustered population: survivor reads / spare-column writes ~5.5 KiB / ~7 KiB
declustered copy back to a replacement ~128 KiB

Degraded reads and rebuild go through 4 KiB stripe units on both raidkm and stock raid6, and with worker groups enabled those units reach the members out of order, so the block layer cannot merge them. With group_thread_cnt=0 the same rebuild merges to ~120–125 KiB on both engines — but runs on one thread, about 2.5–3× slower on a CPU-bound rig; degraded reads improve only to ~7–10 KiB. Chunk-sized rebuild and degraded read are being worked on. Until then, on large-IU flash:

  • chunk = a power-of-two multiple of the IU, with room to grow — 128 KiB covers 16, 32 and 64 KiB units; with k=8 that is a 1 MiB row;
  • keep k × chunk equal to the application's large I/O size (item 1 above);
  • use m=2 for now;
  • no --write-journal and no PPL — an attached md log or PPL turns off full-row batching, which brings back ~5 KiB member writes even at m=2;
  • external filesystem journal on a device that is not QLC — a mirror or an NVMe with power-loss protection;
  • start namespaces, partitions and LVs on an IU boundary as well as a row boundary (mdadm already rounds its data offset to 1 MiB);
  • check, don't assume: raidkm-ab-benchmark.sh and raidkm-standard-benchmark.sh report the member request size per workload.

Worker threads

raidkm already auto-enables raid5 worker groups: total worker threads default to max(num_online_cpus()/2, 2), distributed across num_possible_nodes() groups (the kernel creates one worker group per NUMA node). The single raid5d kthread would otherwise cap stripe handling at ~one core; worker groups are on out of the box — the benchmark above was measured with them — so there is no large "free" win sitting unused. The one knob worth revisiting per host:

  • worker_thread_cnt (/sys/block/mdX/md/worker_thread_cnt) — total worker threads for the array. Recommended user-facing knob because it expresses intent in the natural "I want N parallel workers" mental model. The auto-default is nproc/2 (with a floor of 2 to preserve the win on small hosts) — conservative because workers are CPU-bound and share the box with raid5d, your application, and IRQ handling. Raising further toward nproc may help concurrent/mixed writes: the benchmark table above includes both the auto-default (2 on a 4-core single-NUMA box) and worker_thread_cnt=4, showing +6 / +9 / +7 % on the concurrent/mixed-write tests (1, 2, 3), noise on Test 4, and a small regression on Test 5. On dual-socket 2×16 the default lands at 16 total workers (8 per group × 2 groups); raising to 32 puts it at one worker per core. To override: echo 4 | sudo tee /sys/block/md70/md/worker_thread_cnt.

    Rounding: the written value is divided across num_possible_nodes() groups with ceiling division, so the actual worker thread count may be higher than what you wrote when the total isn't evenly divisible by the node count. E.g., writing 5 on a 2-NUMA box yields 3 per group × 2 groups = 6 workers; the read-back reflects the realized total (6), not what you wrote (5). This never happens on single-NUMA hosts. The choice to round up rather than down ensures you never silently get less parallelism than requested.

  • group_thread_cnt (/sys/block/mdX/md/group_thread_cnt, or the default_group_thread_cnt module param at load). Stock-mdraid-compatible view: threads per worker group (total = group_thread_cnt × num_groups). Same underlying state as worker_thread_cnt; either knob updates the other. Useful when migrating tuning scripts from stock RAID5/6 or when you want explicit per-group control on multi-NUMA hosts.

  • stripe_cache_size — leave at the default 256. Raising it reduced throughput on ramdisk (no device latency to hide, just more cache churn): 256 → 8192 lost ~15-25% on both boxes. It may help on real spinning disks, so measure before changing rather than bumping it blindly.

(Measured on brd ramdisks, which are CPU/memcpy-bound; on real disks the worker- group win should be larger — threads overlap device latency — and the stripe-cache result may invert.)

Filesystem geometry (stride / stripe width)

A write that covers whole rows is a reconstruct-write with no pre-read; one that straddles a row boundary forces a read-modify-write. So a filesystem on a raidkm array should align and size its allocations to the data row, k × chunk.

The array advertises exactly that as optimal_io_size, so mkfs.ext4 picks it up with no options:

cat /sys/block/md70/queue/optimal_io_size    # = k * chunk

This is the data row width, not the pool width — on a declustered array the row is k = g − m cells wide regardless of how many disks are in the pool, so a 14-disk pool with g=6, m=2 advertises 4 × chunk, not 12 × chunk. To set the geometry by hand (4 KiB filesystem blocks, 64 KiB chunk → stride 16):

mkfs.ext4 -b 4096 -E stride=$((chunk/4096)),stripe_width=$((k*chunk/4096)) /dev/md70

After a grow that changes k, the filesystem's stored geometry is stale and every subsequent write lands off-row. mdadm --grow prints the exact refresh command; it is a metadata-only update, no re-write of data:

tune2fs -E stride=16,stripe_width=80 /dev/md70   # then remount

Grows that leave k unchanged (adding parity, expanding a declustered pool) don't affect it and print nothing.

Keep the filesystem journal off the array

Alignment fixes the large writes. What is left is usually a small, frequent write stream sharing the array with them — and on a parity array that is expensive out of all proportion to its size.

A journalled filesystem is the common case. By default the ext4/jbd2 journal is a hidden inode inside the filesystem, so its commits land on the array, interleaved with the data. Those commits are small and sub-row, and every sub-row write is a read-modify-write: read the old data and parity, recompute, write back. The journal is only a few percent of the bytes written and can still dominate the array's read traffic.

Measured on a k=8 m=2 array at 64 KiB chunk under streaming 1 MiB aligned O_DIRECT writes — same filesystem, same workload, only the journal moved:

journal location array member reads journal device
internal (on the array) 43–50 MB per GiB written
external (own device) 0.2 MB per GiB written 39 MB/GiB written, 0 read

The work does not disappear, it just stops being charged parity RMW. Note the symptom this produces if you do not know to look for it: the cost is nearly constant per transaction, so it scales with commit rate rather than with I/O size, and it is easy to mistake for a fixed per-write overhead somewhere in the block layer.

To move it:

mke2fs -O journal_dev /dev/sdX          # format a device as an external journal
tune2fs -O ^has_journal /dev/md70       # drop the internal journal
tune2fs -J device=/dev/sdX /dev/md70    # point the filesystem at the new one

(or mke2fs -J device=/dev/sdX at filesystem-creation time.)

The journal device becomes a correctness dependency. The filesystem will not mount without it, and losing it loses the journal. Give it durability at least equal to the array it serves — a mirror, or an SSD with power-loss protection. An internal journal is parity-protected; an external one is only as safe as the device you put it on.

The general rule generalizes past journals: any small, frequent write stream co-located with large aligned writes will tax them. Metadata-heavy sidecars and write-intent logs behave the same way.

Testing

tools/raidkm-test.sh runs the regression suite — functional (create/write/read/scrub), max-degraded reconstruction, grow (--add-data online reshape incl. degraded-read-after-grow, --add-parity — PARITY_N offline recreate / rotating online COW reshape), the traditional stock --grow --raid-devices syntax (one-line and two-step, verified to grow capacity), and I/O concurrent with a throttled reshape — across PARITY_N and rotating at m=2/3/4, on brd ramdisks:

sudo MDADM=../mdadm/mdadm bash tools/raidkm-test.sh

It loads the module + deps and creates ramdisks itself; point MDADM at the raidkm-aware fork (it refuses a stock mdadm). Exit status is non-zero if any check fails. Individual stages can be run directly (raidkm-test-{functional, degraded,grow,grow-traditional,reshape-concurrent}.sh); see raidkm-test-lib.sh for the env knobs.

Reshape crash/fault suite (tools/raidkm-test-reshape-crash.sh, needs a CONFIG_RAIDKM_FAULT_INJECT kernel build): power-loss and torn-write recovery of the COW-staged online reshape, driven by the raidkm_reshape_inject debug knob. Tier 0 clean reshape · Tier 1 crash+resume at each phase × band · Tier 2 torn STAGE/COMMIT (redo-from-old / replay-from-scratch) · Tier 3 hybrid fault tolerance (frozen mid-reshape: the migrated region survives m_new failures, the pending region m_old, each probed on its own array) · Tier 4 torn COMMIT + concurrent member failure. 114 passed / 0 failed on both base and GFNI (2026-06-11). Scope: this validates reading through faults during a reshape; completing a reshape after losing members mid-flight is not yet supported (migrate_band is non-degraded-read only) — see notes/reshape-cow-design.md §6/§9. Without the fault-inject build the script auto-runs Tier 0 + a best-effort timed crash.

Self-healing suite (tools/raidkm-test-selfheal.sh): writes data, injects silent corruption directly on the raw backing store, and verifies md-kmec reconstructs the corrupt block from parity — on the read path and the m-way scrub, for data-only, parity-only, and mixed data+parity corruption up to m per stripe, confirming the healed_blocks counter advances. Runs in two modes: NATIVE=1 uses raidkm's built-in checksums (also covers detection-after-remount from the persisted CRC region, and rotted-region-page handling — no false heal); the default stacks raidkm on dm-integrity members (needs integritysetup from the cryptsetup package). Validated to m=8 (8 silent corruptions healed in one stripe, beyond RAID-Z3's 3).

Native-checksum cache-thrash suite (tools/raidkm-test-csum-thrash.sh, NATIVE=1): drives an array whose CRC-region footprint is several times the cache ceiling, so region pages are continuously faulted, evicted, written back, and re-faulted, and asserts both directions of the round-trip: no false mismatch or spurious heal across evict+reload, fio data-verify clean under the churn, a REAL corruption of a block whose CRC page was evicted is still detected and healed after re-fault, and a final scrub reports zero mismatches. This is the standing gate for the demand-paged cache and the inline-verify engine.

Real-NVMe re-gate (2026-07-15). The native-checksum stack was re-gated on real 4K-logical-block NVMe (8× GCP local SSD) under a KASAN + lockdep kernel: the functional (12/12), csum-thrash, and self-heal (60/60) suites all run clean with zero KASAN/lockdep splats. The re-gate also exposed — and fixed — a skip_copy×native-checksum read/write WARN_ON (see Zero-copy full-stripe writes above): a 12-round randrw churn that warned in 8/12 rounds is 0/12 after the need_this_block deferral. Two harness bugs that only surface on 4K-logical devices were fixed alongside it (the raw-member probes now use the member's logical block size via blockdev --getss, not a hardcoded 512-byte O_DIRECT).

Repository layout

md-kmec/
├── Kbuild               # top-level kbuild glue (obj-m += km/)
├── Makefile             # build infra; symlinks ../mdraid/md and ../mdraid/isa-l
├── compat/
│   ├── compat-rhel10.h  # RHEL 10.x personality-API shim (force-included by the build)
│   ├── compat-rhel9.h   # RHEL 9.x shim (md_submodule head-style personality)
│   └── compat-vanilla.h # mainline-kernel shim
├── md-rhel9/            # vendored RHEL 9.x md headers (ABI matches in-kernel md-mod)
├── md-vanilla/          # vendored mainline md headers
├── tools/
│   ├── raidkm-test.sh               # run the full test suite (functional/degraded/grow)
│   ├── raidkm-test-lib.sh           # shared helpers sourced by the test scripts
│   ├── raidkm-test-functional.sh    # create/write/read/scrub, PARITY_N+rotating × m=2/3/4
│   ├── raidkm-test-degraded.sh      # max-degraded reconstruction (read + write)
│   ├── raidkm-test-grow.sh          # --add-data (incl. degraded-read-after-grow) + --add-parity
│   ├── raidkm-test-grow-traditional.sh    # stock --grow --raid-devices syntax (one-line + two-step)
│   ├── raidkm-test-reshape-concurrent.sh  # I/O concurrent with a throttled reshape (dual EC tables)
│   ├── raidkm-test-reshape-crash.sh    # power-loss/torn-write recovery of the COW reshape (fault-inject build)
│   ├── raidkm-test-selfheal.sh        # checksum-driven self-heal (NATIVE=1 or dm-integrity)
│   ├── raidkm-test-csum-thrash.sh     # native-checksum region-cache eviction round-trip
│   ├── raidkm-standard-benchmark.sh   # fio harness (7 workloads incl. 1 MiB
│   │                                    # sequential) + member request size,
│   │                                    # Test-7 rebuild/populate wall-clock
│   │                                    # (--rebuild-victim=DEV)
│   ├── raidkm-ab-benchmark.sh         # A/B vs stock md on the same disks:
│   │                                    # raw / raid6 / raid6-intree / raidkm<M>,
│   │                                    # ABBA order, ratio tables
│   ├── raidkm-bench-iosize.sh         # request size + merge share at the members
│   │                                    # per I/O state (healthy, degraded, rebuild,
│   │                                    # declustered populate/copyback); null_blk rig
│   ├── raidkm-member-stats.sh         # sourced helper: member request counters
│   └── raidkm-create.sh               # sysfs array creation; needs adapting
│                                    # to "raidkm" name / level 71
└── km/
    ├── Kbuild           # builds raidkm.ko = raid_km.o + raid_km-cache.o + raid_km-ppl.o
    ├── raid_km.c        # fork of mdraid/md/raid5.c with effective_level dispatch
    ├── raid_km.h        # fork of raid5.h (RAID_KM_LEVEL, is_raid6_math, effective_level)
    ├── raid_km-cache.c  # fork of raid5-cache.c (journal, dormant unless attached)
    ├── raid_km-ppl.c    # fork of raid5-ppl.c (partial parity log, ditto)
    ├── raid_km-log.h    # fork of raid5-log.h
    └── raid0.h          # fork (for the takeover stubs to link)

Building

Requires built mdraid in a sibling directory for isal_lib.ko's exports.

cd ../mdraid && make     # produces isal_lib.ko + raid456.ko etc.
cd ../md-kmec && make    # produces km/raidkm.ko

isal_lib.ko's exports are prefixed isal_lib_ so they cannot collide with another out-of-tree module that vendors the same ISA-L code under the upstream API names — the kernel matches exported symbols by bare name and rejects the second module to load, which would leave raidkm.ko unloadable depending on boot order. The rename is a macro in ../mdraid/isa-l/isal_lib_syms.h applied to definitions and call sites alike, so raid_km.c still reads in the plain ISA-L names (ec_encode_data_base, gf_invert_matrix, …) and nothing about building or loading raidkm changes. The prefix shows up only in nm km/raidkm.ko and in unresolved-symbol messages.

Kernel targets

One source tree builds against three kernel flavours. The build picks a TARGET from the running kernel release and force-includes the matching compat/compat-*.h shim, so the verbatim raid5.c fork compiles against each kernel's personality API (register_md_submodule etc.) unchanged:

TARGET Selected when md headers Notes
rhel10 .el10 release built mdraid tree flat md_personality with .level
rhel9 .el9 release md-rhel9/ md_submodule_head-style personality (level comes from head.id)
vanilla anything else md-vanilla/ mainline

Override the auto-detection with make TARGET=rhel9 (useful when building against a KDIR whose release string doesn't carry the distro suffix, e.g. a locally-built debug kernel).

RHEL 9 support is production-grade: the full 12-suite matrix (functional, declustered create/io/degraded/csum/populate/rebalance/autoarm/multi/crash, checksum thrash, self-heal) passes 211 distinct checks under a KASAN + lockdep kernel with zero splats. The vendored md-rhel9/ headers come from the distro kernel source, so the ABI matches the in-kernel md-mod by construction rather than by inspection.

Managing raidkm arrays with mdadm

Stock mdadm rejects level 71. A patched mdadm 4.4 that understands raidkm/level 71 lives in the sibling mdadm checkout (branch raidkm-level71). On the CLI, placement and count are separate: --layout=rotating|parity-last and --parity-count=N. Internally raidkm packs both into the v1.x superblock layout field — the low byte carries m (2–8) and bit 0x100 selects rotating (clear = parity-last) — but that packing is an implementation detail you don't type. Under parity-last, data lives on disks [0, raid_devices − m) and never moves; under rotating the m-slot parity block rotates one disk per stripe. No raidkm-specific superblock code is needed — md core round-trips level 71 and the packed layout through the standard v1.2 superblock.

Version pairing. raidkm.ko and the patched mdadm are co-dependent — features land across both repos together (level-71 create / assemble / grow, and the PPL consistency policy). Build and run them as a matched pair: the mdadm fork that goes with this tree is branch raidkm-level71, currently at commit 24e99c1b ("raidkm: --grow --remove-parity + fix add-parity dropping the csum layout bit"). When you advance one repo, rebuild the other from its matching commit. The fork will be wired in as a git submodule once it has a published remote (scopedog/mdadm); until then it lives in the sibling mdadm checkout and the pairing is tracked here by hand.

Build it (userspace; NO_LIBUDEV avoids the libudev build dep):

cd ../mdadm && make CXFLAGS=-DNO_LIBUDEV mdadm

Load the personality and its dependencies (the async_tx family is not pulled in by raid6_pq alone), then isal_lib.ko, then raidkm.ko:

for m in async_tx async_memcpy async_xor async_pq async_raid6_recov raid6_pq; do
        modprobe $m
done
insmod ../mdraid/isa-l/isal_lib.ko
insmod km/raidkm.ko

Create

Two independent knobs: --parity-count=N sets the parity-disk count m (2–8, default 2; alias --parities), and --layout= sets the placement — rotating (default) or parity-last (aliases dedicated/ fixed). Data disks = raid-devices − m. An optional third knob, --checksum[=crc32c], enables native per-block CRC-32C integrity with checksum-driven self-healing (bare --checksum defaults to crc32c; --integrity=crc32c is the retained alias).

# 3 data + 2 parity (m=2, raid6-equivalent fast path), rotating (default)
mdadm --create /dev/md70 --level=raidkm --parity-count=2 \
      --raid-devices=5 --chunk=64 /dev/ram0 /dev/ram1 /dev/ram2 /dev/ram3 /dev/ram4

# 3 data + 4 parity (m=4, Cauchy matrix), parity-last (dedicated tail parity)
mdadm --create /dev/md70 --level=raidkm --parity-count=4 --layout=parity-last \
      --raid-devices=7 --chunk=64 \
      /dev/ram0 /dev/ram1 /dev/ram2 /dev/ram3 /dev/ram4 /dev/ram5 /dev/ram6

# 4 data + 3 parity (m=3), rotating — parity spread across all 7 disks
mdadm --create /dev/md70 --level=raidkm --parity-count=3 --layout=rotating \
      --raid-devices=7 --chunk=64 \
      /dev/ram0 /dev/ram1 /dev/ram2 /dev/ram3 /dev/ram4 /dev/ram5 /dev/ram6

# m=2 with native per-block checksums (integrity + self-healing) enabled
mdadm --create /dev/md70 --level=raidkm --parity-count=2 --checksum=crc32c \
      --raid-devices=5 --chunk=64 /dev/ram0 /dev/ram1 /dev/ram2 /dev/ram3 /dev/ram4

Deprecated: the older packed form --layout=N (parity-last) / --layout=Nr (rotating), which crammed m into --layout, is still accepted for back-compat but prints a warning; prefer --parity-count

  • --layout=rotating|parity-last. Note the default flipped with the new syntax: omitting --layout now means rotating (it used to mean PARITY_N), so a bare --layout=2 still means parity-last as before.

--detail / --examine report Raid Level : raidkm, Layout : rotating (or parity-last), and Parity Count : <N>.

Assemble

mdadm --stop /dev/md70
mdadm --assemble /dev/md70 /dev/ram0 /dev/ram1 /dev/ram2 /dev/ram3 /dev/ram4

A degraded array (up to m missing members) assembles with --run; missing data is reconstructed on read:

mdadm --assemble --run /dev/md70 /dev/ram0 /dev/ram1 /dev/ram2 /dev/ram3 /dev/ram4

Grow

raidkm --grow has two role-tagged forms; each disk you list is added in the named role, so you never compute the new device count yourself:

command what it does layouts mechanism
--grow --add-data <disks> add data disk(s) — grow capacity, m fixed PARITY_N and rotating online kernel reshape
--grow --raid-devices=<N-1> REMOVE one data disk — shrink capacity, m fixed PARITY_N and rotating online backward COW reshape; --array-size clamp first (mdadm prints the value)
--grow --add-parity <disks> add parity disk(s) — raise m, k fixed PARITY_N and rotating PARITY_N: offline recreate. rotating: online COW reshape (no backup-file, crash-safe via the kernel journal); offline windowed relocation retained as a fallback (MDADM_RAIDKM_OFFLINE_ADDPARITY) — see below
--grow --remove-parity drop one parity disk — lower m (≥2 remain), k and capacity fixed PARITY_N and rotating online COW reshape (k fixed ⇒ every row re-encodes in place; no --array-size dance); the freed member becomes a spare (remove + --zero-superblock before reuse). The m=4→3 Cauchy→Vandermonde boundary is handled by the re-encode. Classic only (declustered refused)

The traditional (stock) --grow syntax also works. Because parity in a stock RAID6 is fixed, growing --raid-devices there means add capacity, so on raidkm an untagged grow that gives an explicit --raid-devices=N is treated as --add-data (grow data at fixed m). Both stock entry forms work — the one-line --add and the classic two-step where you add hot spares first:

mdadm --grow /dev/md70 --raid-devices=5 --add /dev/ram5   # one-line: == --add-data
# …or…
mdadm /dev/md70 --add /dev/ram5 /dev/ram6                 # add spares (MANAGE)
mdadm --grow /dev/md70 --raid-devices=6                   # grow into them == --add-data

A bare --grow --add <disks> with no --raid-devices keeps the raidkm shorthand of adding parity (alias for --add-parity). An explicit --add-data/--add-parity always wins, so use those when you want to be unambiguous.

--add-data — grow capacity (online reshape)

mdadm --grow /dev/md70 --add-data /dev/ram5            # k=3 → k=4 (m unchanged)
mdadm --grow /dev/md70 --add-data /dev/ram5 /dev/ram6  # k=3 → k=5

Adding a data disk changes the stripe width, so every block relocates — a true restripe. raidkm drives the inherited kernel online reshape (delta_disks at a fixed parity count): the new disk(s) are added as spares, raid_disks is bumped, and the kernel relocates the array stripe-by-stripe with a crash-safe reshape_position checkpoint. Works for both layouts (the relocation rides the layout-aware sector mapping). The array stays readable/writable throughout, and a grow needs no backup file (the wider new layout writes behind the old layout's read frontier, so nothing unread is overwritten — and a crash resumes from the checkpoint). The layout is immutable across a grow. Monitor with /proc/mdstat or --detail.

--add-parity — add a parity disk (both layouts)

Adding parity raises m (more fault tolerance) at a fixed data-disk count. How it's done depends on the layout, because the two layouts place parity differently:

PARITY_N — offline recreate (cheap, no data movement). PARITY_N keeps data on a fixed prefix of disks and never relocates it, so adding parity only appends a parity disk and recomputes parity for the new m. There is no in-kernel online reshape for a parity-count change (it would alter max_degraded), so --grow does it offline but data-preserving: it stops the array and recreates it at the new m (same device order, data_offset, UUID and name), then md's normal resync recomputes parity while the array is online. You can add several parity disks in one command.

mdadm --grow /dev/md70 --add-parity /dev/ram5             # m=2 → m=3
mdadm --grow /dev/md70 --add-parity /dev/ram5 /dev/ram6   # m=2 → m=4
mdadm --grow /dev/md70 --add /dev/ram5                    # legacy alias (= --add-parity)

The array is usable throughout the background resync, but is not fully fault-tolerant until the resync completes (the same window as any md rebuild), and the brief stop+recreate is not crash-safe. On real (non-identical) disks the recreate must reuse the original data_offset; on uniform disks mdadm picks it deterministically.

Rotating — online COW reshape (default). Under rotating parity every block moves when m changes, so there is no cheap append. raidkm drives an online, journaled, copy-on-write reshape entirely in the kernel: it adds the new disk, then migrates the array one band at a time, staging each band's new-geometry stripe out-of-place (in the metadata gap below a constant data_offset) and journaling STAGE→COMMIT→DONE before overwriting the band's home location. Because no live block is overwritten until its new-geometry copy is durably staged, correctness is placement-agnostic — the read/write location-aliasing race that sank the earlier in-place attempt (withdrawn 2026-06-01) is structurally impossible. The data-disk count k is unchanged, so array_size and data_offset stay constant; only m (hence max_degraded and the parity placement) changes.

The array stays readable/writable throughout and needs no backup file: a power loss mid-reshape is recovered by a plain mdadm --assemble, which replays the in-kernel journal (raidkm sets RESHAPE_NO_BACKUP, so mdadm neither demands a backup-file nor runs its critical-section restore). Add one parity disk per run; raise m further with repeated runs. The same reshape is also drivable through device-mapper / LVM.

mdadm --grow /dev/md70 --add-parity /dev/ram5   # m=2 → m=3 (rotating), online

Validated. Whole-array m=2→3 (base) and m=3→4 Cauchy (GFNI) — data byte-identical + scrub=0 + new-m-degraded-read EC oracle — plus a true power-loss crash (dm-flakey drop_writes) recovered by a plain --assemble. The reshape crash/fault suite (tools/raidkm-test-reshape-crash.sh) is 114 passed / 0 failed on base and GFNI (2026-06-11).

Scope. Reading through faults during a reshape is supported; completing a reshape after losing members mid-flight is not yet (migrate_band is non-degraded-read only) — see notes/reshape-cow-design.md §6/§9.

Fallback — offline windowed relocation. For kernels without the COW engine, the pre-COW offline path is retained behind MDADM_RAIDKM_OFFLINE_ADDPARITY=1: it stops the array, relocates data on the raw members in ≤64 MiB batches (each backed up to --backup-file first for crash rollback — bounded scratch, a 64 MiB window, not array-sized), then recreates at m+1 and lets resync rebuild parity. Crash-safe/resumable via a <backup-file>.raidkm-state sidecar (re-run the same command to roll back the in-flight window and continue), but the array is offline for the duration.

MDADM_RAIDKM_OFFLINE_ADDPARITY=1 \
  mdadm --grow /dev/md70 --add-parity --backup-file=/var/tmp/rk.bak /dev/ram5

To add a hot spare (not part of the array yet), use MANAGE-mode --add without --grow: mdadm /dev/md70 --add /dev/ram5.

Declustered parity

Wide erasure-coded pools have a rebuild problem: a classic k+m array with one dedicated spare funnels the entire reconstruction of a failed member through that one replacement disk, so rebuild time grows with member size and the array stays degraded (and exposed to a second failure) the whole time. Declustered parity fixes this by making the stripe narrower than the pool: k+m groups are scattered over an N-disk pool by a balanced permutation, and the spare is distributed as a rotating set of spare columns rather than one disk. When a member dies, its lost chunks live on — and are rebuilt across — every survivor in parallel, so single-disk rebuild parallelises ~(N−1)/g instead of bottlenecking on one writer.

The construction is clean-room: the rotation/difference permutation is classic design-theory combinatorics (Holland & Gibson lineage); it is the same idea as OpenZFS dRAID but shares no CDDL code — this is a GPL implementation. Capacity balance is exact by construction, and the per-survivor rebuild load is rotation-symmetric.

The headline (wide-pool rebuild): rebuilding a failed 16 GB member on an N=80, g=13 (11+2) pool took 44.9 s vs 785.1 s for a classic 78+2 array — 17.5×. It even beats a narrow 11+2 classic (51.9 s), because the distributed spare removes the single-writer bottleneck.

Rebuild load — where the win comes from

The wall-clock headline above follows from where the rebuild I/O lands. A classic rebuild funnels every reconstructed byte onto the one hot spare — so rebuild throughput is capped by a single disk's write speed no matter how wide the array — while a declustered rebuild spreads those writes across the whole pool. Measured with exact per-disk I/O counters (tools/raidkm-bench-declustered-rebuild-load.sh), which makes the ratios device-count-independent (so they hold on any box, not just a wide physical rig):

Pool Group Rebuild-write funnelling¹ Survivor read at rebalance²
N=14 g=6 (4+2) 14.2× 5.0×
N=42 g=10 (8+2) 42.5× 9.3×
N=80 g=13 (11+2) 85.0× 12.6×

¹ Write funnelling = busiest single disk's write during the rebuild, classic ÷ declustered. In the N=80 classic rebuild all 255 MiB of write hit the one spare (write spread 13.4× across the 13 members); declustered's busiest disk wrote just 3 MiB (spread 1.5×). The ratio ≈ N — the wider the pool, the more the single-writer bottleneck is removed.

² Survivor read = total bytes read off survivors to bring a fresh disk back, copy-from-spare rebalance ÷ decode rebuild. Decode reads g−1 survivors per lost chunk; copy-from-spare reads the already-populated spare (~1 disk's worth). The ratio ≈ g−1. (Copy front-loads a one-time population decode paid when the disk first failed; the harness reports it separately.)

Counters are exact regardless of device count; the wall-clock headline needs many physical spindles, but these distribution ratios are the mechanism it follows from.

Creating a declustered array

mdadm --create /dev/md0 --level=raidkm --parity-count=2 \
      --layout=declustered --group-width=6 --spare-columns=2 \
      --raid-devices=14 /dev/sd[b-o]
  • --group-width=g — the stripe width k+m (here 6 = 4+2). Each row of the pool holds ngroups = (N−s)/g independent groups plus s spare columns.
  • --spare-columns=s (optional) — distributed-spare capacity, in columns per row (≈ s disks' worth of spare, spread across all members). Defaults to the smallest legal value; s ≥ m lets the pool absorb m concurrent failures.
  • --dcl-nbase=n / --dcl-seed=x (optional, advanced) — the number of base permutations and the acceptance-search seed. mdadm runs the acceptance search at create time (float scoring is userspace-only) and records the winning seed + geometry in a per-member on-disk rkdcl metadata block; the kernel regenerates the identical permutation from that seed alone.

Constraint C1: (N − s) mod g == 0 (groups tile a row without wrapping). mdadm validates this and suggests a legal --spare-columns if you miss it. For example N=80 wants g=13/s=2 (good) but g=16 would force s=16 (12.5% overhead) — so mdadm steers you.

Rebuilding a failed member (population)

A declustered array does not rebuild onto a single replacement. Instead it populates the failed member's content into the distributed spare columns:

# a member failed; reconstruct it into the distributed spare
echo <failed-index> > /sys/block/md0/md/rk_dcl_populate
cat /sys/block/md0/md/rk_dcl_populate      # populating <X> -> spare <j> mark A/B

Or set echo 1 > /sys/block/md0/md/rk_dcl_auto to arm population automatically the moment a member fails (off by default; not persisted). Population runs as a raidkm-owned sync action with a crash-safe journaled prefix mark (FUA + PREFLUSH every 16 MiB) — a power loss mid-rebuild resumes from the mark on the next assembly. Up to s failures can be populated sequentially (one at a time); because a spare column can rotate onto another failed disk, the slot→disk map resolves through the active assignments (chained redirects), and the on-disk journal is adaptive v2/v3 so an older module fails closed rather than silently dropping an assignment.

Migrating back to a fresh disk (rebalance)

When you add a replacement, the array rebalances — it copies the failed member's already-reconstructed content straight from the distributed spare onto the new disk (--layout-transparent; just --add the replacement):

mdadm /dev/md0 --add /dev/sdp        # copy-from-spare onto the replacement

This copy-from-spare path (16 parallel workers, no GF decode, the array never degraded during it — reads are served live from the replacement below the copy mark and from the spare above it, split at a strict-journaled per-band mark) is faster and safer than a decode rebuild, and falls back to the validated decode leg on any persistent copy fault. After it completes the spare columns are freed and the pool is whole again.

Native checksums on a declustered array

--checksum composes with --layout=declustered:

mdadm --create /dev/md0 --level=raidkm --parity-count=2 \
      --layout=declustered --group-width=6 --spare-columns=2 --checksum \
      --raid-devices=14 /dev/sd[b-o]

Each member's tail holds both reserves, stacked — the rkdcl metadata block first, then the CRC region (the kernel derives the CRC region one chunk past the data area). CRCs are keyed by the physical pool disk a block lives on, not its logical slot, so a block served through the distributed-spare redirect still verifies against the disk it was read from, and the checksum-driven self-heal reconstructs a silently-corrupt block from its group's parity exactly as on a non-declustered array. The copy-from-spare rebalance migrates each copied block's CRC from the spare disk's key to the replacement's alongside the bytes (never recomputing — a torn or rotted copy is copied verbatim but its original CRC travels with it, so the first read of the new disk detects the mismatch and heals by decode). Gate: tools/raidkm-test-declustered-csum.sh.

Chunk-aligned reads take the same direct read bypass as classic raidkm (2026-07-19): a healthy declustered array serves an in-chunk read straight from the mapped pool disk (forward permutation + redirect chain), never touching the stripe cache; with native checksums the bypass read is still verified at completion against the physical disk's CRCs, and a mismatch is rechecked and healed through the stripe cache. Gate: tools/raidkm-test-declustered-aligned.sh.

Online pool expansion (mdadm --grow --raid-devices=N')

A declustered array can be widened — add disks to the pool and it re-tiles each row into more k+m groups, growing capacity and rebuild parallelism while keeping groups narrow. Group width g, parity m and spare-column count s stay fixed (so (N'−s) mod g == 0 must hold, which mdadm checks); only the pool size and the permutation change. Usage:

mdadm --add    /dev/mdN /dev/newdisk...     # add the new pool disks as spares
mdadm --grow   /dev/mdN --raid-devices=N'   # widen the pool

mdadm runs the acceptance search for the new pool's permutation seed, then the kernel migrates the array one band (device row) at a time with a COW-staged, journaled reshape: each new row is read from the old permutation, re-encoded per group, staged to a scratch region, then committed to its home — so a power loss mid-reshape is recovered by a plain mdadm --assemble replaying the journal, no backup file. The array stays readable/writable throughout. Validated on real kernels (m=2 and m=3): data intact across the reshape, scrub clean, degraded-read EC-correct, and crash-recovered from clean-stop, atomic power-loss (dm-flakey) and deterministic torn STAGE/COMMIT injection. The COW-staged reshape engine is described in notes/reshape-cow-design.md; gates tools/raidkm-test-declustered-reshape*.sh.

Online group-geometry reshapes (--add-parity / --add-data / --spare-columns)

The within-group geometry can also be changed online, through the same journaled COW engine:

mdadm --grow /dev/mdN --add-parity <newdisks>   # m -> m+1  (g -> g+1; capacity fixed)
mdadm --grow /dev/mdN --add-data   <newdisks>   # k -> k+1  (g -> g+1; capacity grows)
mdadm --grow /dev/mdN --spare-columns=s'        # s -> s' (either direction)
mdadm --grow /dev/mdN --raid-devices=<N-g>      # pool shrink: remove one group's disks

add-parity/add-data take one new disk per group (ngroups disks); spare-count runs on the fixed pool (delta_disks == 0). The capacity-shrinking kinds — pool shrink and spare-count increase — run the same engine backwards (the sparser new geometry packs data deeper, so the walk starts at the top row and descends) and are gated array-size-first: shrink the filesystem, clamp with --grow --array-size=<kb> (printed exactly on refusal), then reshape. After a pool shrink the departing members become spares — --remove and --zero-superblock them before reuse (they hold a readable pre-shrink copy and are never auto-zeroed; a member failure before their removal will legitimately pull them back in as hot-spare rebuild targets). While the migration walks the array, the not-yet-migrated region is served by old-geometry stripes — the stripe carries its geometry (old group width, old parity count, old EC tables) so concurrent reads and writes on either side of the frontier are correct, the same mechanism at a different axis as pool expansion's dual permutation maps. Crash recovery is identical (journal v2 records both geometries). Native-checksum arrays run these online too: CRC store/verify on the stripe path is keyed by the stripe's own geometry, and the band's CRC re-key runs inside the migrating row's claim/quiesce bracket, so concurrent I/O never sees a stale key.

What v1 does not do

A declustered array currently refuses (rather than running wrong): a file-backed write-intent bitmap (the internal bitmap and online member resize are supported), a pool shrink of more than one group per reshape, and online member-size shrink. The group-geometry reshapes — per-group add-parity (--grow --add-parity), add-data (--grow --add-data) and spare-count change in either direction (--grow --spare-columns=s') — run online like pool expansion: the un-migrated region is served with old-geometry stripes while the journaled COW migration walks the array — native-checksum arrays included.

Capacity-shrinking reshapes run online too (backward COW walk): a pool shrink removes one group's worth of disks (--grow --raid-devices=<N-g>; the departing members become spares at completion — --remove + --zero-superblock them before reuse), and a spare-count increase trades capacity back into spare columns. Both are gated array-size-first: shrink the filesystem, clamp with --grow --array-size=<kb> (mdadm computes and prints the exact value on refusal), then reshape. Crash-safe like every other kind: a mid-walk stop or power loss recovers by plain --assemble and resumes the descending walk.

Validation gates: tools/raidkm-test-declustered-*.sh.

Managing raidkm via device-mapper and LVM

Besides mdadm, raidkm arrays can be driven through device-mapper — either directly with dmsetup (the kernel dm-raid target), or, the managed way, as LVM logical volumes. This needs no new dm target: dm-raid already stands up an mddev and runs whatever personality md_run() selects by level, so level 71 rides the existing target. (The enabling dm-raid.c changes live in the mdraid fork; full design and validation are in notes/dm-raid-design.md.)

dmsetup (raw device-mapper)

# 3 data + 2 parity (m=2), rotating, 512 KiB chunk, over 5 devices:
dmsetup create kmtest --table \
  "0 <sectors> raid raidkm 3 1024 parity_count 2 5 - /dev/ram0 - /dev/ram1 - /dev/ram2 - /dev/ram3 - /dev/ram4"
dmsetup status kmtest            # health string + sync_action + mismatch_cnt
dmsetup message kmtest 0 check   # scrub

Degraded = suspend / reload-with-- - in the victim slot / resume. Rebuild = reload with a fresh device + a rebuild <idx> parameter. raidkm is the rotating layout; raidkm_n is parity-last.

LVM (lvcreate --type raidkm)

The lvm2 raidkm fork registers two segtypes — raidkm (rotating) and raidkm_n (parity-last) — each carrying the parity count m:

lvcreate --type raidkm --paritycount 2 -i 3 -L 1G -n data vg   # 3 data + 2 parity
lvconvert --repair vg/data                                     # raidkm-aware leg replace + rebuild
lvchange --monitor y vg/data                                   # dmeventd auto-repair

Create / activate / I/O / reassembly / degraded read, lvconvert --repair, and dmeventd auto-repair are validated for m=2/3/4 on both the base and GFNI EC paths. Because the dm/LVM reshape path (out-of-place via data-offset) does not fit raidkm's layout, growing/shrinking a raidkm LV is not supported via LVM — use mdadm --grow for capacity/parity changes (the kernel gate rejects dm reshape).

An LVM raidkm LV is an ordinary cache origin, so it can be fronted by lvmcache (lvconvert --type cache) — e.g. a fast tier over the EC capacity tier under a filesystem. See notes/rhel9-lvmcache-ost.md (still only on the legacy rhel9-port branch) for an end-to-end dm-cache → dm-raid(raidkm) validation on RHEL 9.

License

GPL-2.0-only. See LICENSE.

About

raidkm — k+m Reed-Solomon md RAID personality (md level 71) accelerated with ISA-L GFNI

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages