Repository navigation
feat(layer-stream): stripe the disk-tier layer cache across NVMe drives - #1536
Conversation
…root (R4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The cold 70B-shaped step is read-bound at 4.1 GB/s from one NVMe while the dev box carries two (C: on a CPU root port, D: behind the chipset). Before any sharder or reader code stripes a store across both, measure whether the two drives deliver more together than apart. This commit holds the decision rule (record §2) and the harness only; no byte has been read yet. The harness imports the read primitive from issue974_unbuffered.py so the drives are read exactly as gate-974 read them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s (R1) Run 1 read C: alone at 3.855 GB/s (median), outside the committed 4.5-6.2 validity row, so the rule gives no verdict and none is drawn from its r medians. The box was at its commit limit when the run started (56.65 of 56.69 GB, pagefile on C:), which fits and does not prove paging I/O on C:. Disclosed, not corrected: the validity bound was taken from a summary that kept gate-974's best readings; gate-974's own K=4 one-request readings are 5.12 and 4.18 GB/s by position. The row stays as committed. Before run 2 the harness samples PDH counters per arm (per-volume read and write bytes, paging, commit, CPU time and clock), so foreign writes during an arm are measured rather than inferred, and the box stamp no longer loses the Python-process count to tasklist's OEM code page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…1 (R1) Run 2 reproduces run 1 (C: alone 3.99 GB/s median, r_sum 2.03, r_alt 1.80) and again gives no verdict under the committed validity row. Its counters show 0.2-1.8 MB/s of foreign writes on C: during the C: arms and an awake CPU, so neither the pagefile nor a peer's suite explains C: reading ~4 GB/s. Each drive still reads faster beside the other than alone, and the power plan has PCIe ASPM at maximum power savings on AC. D1 separates the two host mechanisms (CPU package idle vs link ASPM) by reading C: alone with and without spinner processes; its rule is committed here, before it runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…(R1) Two spinner processes moved C:'s single-drive read by a median +3% (1.059, 1.029, 0.860 by round), so neither decisive D1 row holds and no conclusion is drawn. C: alone ranged 3.62-4.68 GB/s over the day's nine alone arms. R1's committed rule has given no verdict twice and is not edited. The last host suspect, per-link ASPM (maximum power savings on AC), needs a power setting changed on a shared box, so it waits for the owner. Below any verdict: the two drives read 7.65-9.15 GB/s together in six of six arms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the owner's consent, D2 switches the active scheme's AC PCIe ASPM index between the box's own value (2, maximum power savings) and 0 between arms while C: is read alone, then re-runs R1 once with ASPM Off. The original index is restored in a finally block and read back; the harness refuses to run on battery. R1 run 3 is judged by the unchanged R1 rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-layer barrier AsyncDiskSource._read_layer blocks until every range of a layer lands, so one layer is ever in flight: striping files alone could not speed anything up, and the "reader gap" may be this barrier or nothing (the same primitive with the same barrier reads C: at 3.62-4.68 GB/s today). R3' reads one drive at depth 1 and depth 2 through one function, same K workers, interleaved. Also records that the first D2 attempt refused on battery before touching any power setting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t from this box D2 (ASPM on vs off, interleaved, owner-consented and restored): off/on 1.027 / 0.996 / 0.910, median 1.00 — ambiguous by its rule, and no round shows ASPM costing 10%. R1 run 3 under ASPM Off: C: alone 3.837 GB/s, the validity row fires a third time, so R1 has no verdict from this box; the two drives still read 7.80-8.45 GB/s together. R3' (depth 2 vs the reader's depth 1): 1.106 / 1.101 / 0.963 — ambiguous, never a loss. C: alone spans 3.52-4.68 GB/s over the day's seventeen arms, the size of every effect the diagnostics looked for; three rounds cannot resolve 10% here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Counted from the JSON: runs 1-3, both arms of D1 and D2, R3' at depth 1; 3.52-4.68 GB/s, median 3.90. The previous commit's message says seventeen; this corrects the record, not that message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ck reads it (R4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…name orphans (R4) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…mits (R4) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…failing a committed shard (R4) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… (R4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…striped (R4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ve (R4) Deviations from the task brief, each needed to keep a suite honest: - A one-drive source keeps the pool name soup-layer-range; only a striped source names pools soup-layer-range-r<root>. The #974 and #1056 tests match the historical names. - _read_layer stays the reader's one call per layer read, now non-blocking: it submits the ranges and returns their outcomes. test_issue974 pins that call; a bracket around it (issue974_warm_stages reader_read) now times the dispatch, not the read. - The steady-state test's consumer computes 25 ms per layer. With no compute the drives run in phase and k+1 overlaps k for every other k only: the brief's count passed this reader 0/10 and a batch reader 0/10; with the pause, 10/10 against 0/10. - The staging-region test compares range reads, not per-layer merged spans: layers read on both legs of the walk made one span cover everything between, 7 false clashes. - New posed-state test for exclude: no timed walk reaches an in-flight slot being claimed, so a mutant ignoring exclude passed every other test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sides (R4) The count sat exactly on its threshold: 5 of 6 boundaries required, and boundary 2 never overlaps. Now 16 layers, 100 ms reads and 50 ms of consumer compute, counting only the 12 boundaries after the cold start (k = 3..14), threshold 6. Expected: this reader 12 of 12, with about 50 ms of margin per boundary (about 32 ms if a Windows 3.10 sleep overruns by one 15.6 ms tick). A batch reader gets 0 of 12, because reads from different batches never overlap. Measured: this reader MIN 11 over 25 runs, MIN 12 over 20 runs with a simulated 15.6 ms tick, and MIN 12 over 10 runs under CPU burners. The batch mutant got MAX 0 over 12 runs (and 10 of 10 pytest runs failed). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he placement (R4) --stripe-dir (repeatable) adds extra cache roots on other NVMe drives, passed to shard_checkpoint's stripe_roots the same way soup train's setup path would; the distinct-volume/NVMe-kind rules stay the operator's job in this harness (the sharder only checks existence/symlink/overlap, deliberately not re-running the ~9 s probe resolve_stripe_roots does at setup time). build()'s shard_checkpoint call reads stripe_dir via getattr with a [] default rather than args.stripe_dir directly: head_prefetch_ab.py, issue901_stream_step_probe.py, issue974_warm_stages.py and layer0_wait.py each hand-build their own bare argparse.Namespace and call stream_probe.build() directly, none of them spelling a stripe_dir field, and a hard attribute read would have raised AttributeError in all four on their next GPU run. The JSON record and the live source-summary print both gain what the gate must be able to trust: stripe_dirs/stripe_roots/layer_roots (what was asked for vs. what the index actually placed), direct_io (a pinned staging region that is not sector-aligned silently turns it off for every drive), and open_per_root (which opener each drive actually got). No behaviour change when --stripe-dir is not given: shard_checkpoint's default is already stripe_roots=(), so passing stripe_roots=() explicitly is identical. Verified with ruff, --help, and a CPU-only smoke exercising the new argument parsing, the getattr fallbacks (RAM-tier source, a Namespace missing stripe_dir), the index/source field extraction and the >12 layer-roots truncation -- no GPU measurement or sharding was run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The record for the dev-box gate that decides whether two-drive striping ships: the question, the fixture and the two arms, the decision rule copied verbatim from the task brief, what the gate does not measure, and the per-arm box-state protocol. No arm has run and the striped cache does not exist yet; results and verdict are pending. The SINGLE arm's one-root cache was checked on the CPU with the sharder's own cache check at 22:57 (hit, format 5), so it is reused as is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Runs the gate record's section 1 command once per arm, one process per arm and never two at once, in the order the rule fixes (r0 SINGLE, STRIPED; r1 STRIPED, SINGLE; r2 SINGLE, STRIPED). Around every arm it takes the section 4 box stamp before and after (AC and battery, available RAM, commit used/limit, Python processes, nvidia-smi compute apps and total GPU memory, the AC ASPM index read-only, soup_cli.__file__ as the arm's environment resolves it), and while the arm runs it samples AC and commit every 2 s and PDH counters every 1 s (C: and D: read/write bytes, paging, commit, CPU). It applies only the two rule rows one arm can settle (a SINGLE arm outside 15.0-21.5 s; direct_io or pinned not true) and stops when one fires. An arm that sees battery power or writes no step_plain point is kept under a _void name and re-run once in the same position. --plan build runs one untimed arm to build a cache. No torch import. Committed after the rule (6f1d637) and before any arm or build run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… arm ran (R4) The first build attempt (2026-09-28) never started an arm: battery, then commit headroom under peer suites, then the laptop slept with its lid closed and the driver's pre-arm window expired on wake. A step spanning a suspend reads as a slow step, so an arm with two power samples more than 30 s apart is now void. The record's section 4 says so and its status line discloses the amendment; section 2 (the rule) is unchanged. gate_driver.json is the aborted attempt's log. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sequence stopped at the fourth of six arms. The box lost AC power 27 s into round 1's SINGLE arm, which made it void (record §4). It then stayed on battery for the whole 15-minute pre-arm window, so the void arm's re-run and round 2 never ran. The §2 rule needs three rounds. §2 and §4 are unchanged. Arms that ran, published as measured (cold 70B-shaped NF4 step, seq 512): STRIPED 10.320 s (round 0) and 10.340 s (round 1), SINGLE 17.414 s (round 0, 1.687x), direct_io and pinned true in every arm. The void SINGLE arm (16.979 s) is kept under a _void name and not used. After the sequence ended, the striped cache was compared tensor by tensor with the single-root one (new benchmarks/harness/compare_shard_caches.py, no torch, no mmap): all 83 files, 2,405 tensors and 36.388 GB are equal. Recorded anomalies: the driver's not_run list omits the void arm's re-run (it counts any attempt with an exit code as run); the loss differences are 7-16x the one cross-process difference on record. Build run: sharding 231.812 s, layer roots exactly i mod 2. The pre-commit hooks added final newlines to the JSON files and trimmed trailing whitespace in two arm logs (whitespace only; git diff -w is empty). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…3's files (R4) Attempt 3 (c544ad1) stopped after one full round when the box went on battery, so the rule needs a new complete six-arm sequence. The driver named the round arms gate_r<r>_<arm>, which a re-run would overwrite while the record's section 5 still cites them. --run-prefix (default "gate", so attempt 3 is unchanged) names the new sequence's files. gate_driver.json gains one entry: a --dry-run of the new flag, killed during its pre-arm wait (the box was on battery). It ran no arm and is kept because the file logs every invocation. The rule (section 2) and the box-state bars (section 4) are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sequence 2 (gate2_* files, a new complete sequence, driver at 7599360 with --run-prefix gate2) stopped after four of six arms on the rule's first row: round 1's SINGLE arm read 22.107 s, outside 15.0-21.5 s (its plain steps were 31.581, 17.765 and 16.974 s), so the session did not reproduce the known step. Round 2 was not run. §2 and §4 are unchanged. Sequence 2, as measured: round 0 SINGLE 18.133 s, STRIPED 11.367 s (1.595x); round 1 STRIPED 18.052 s, recorded but not reached by the verdict rows because the validity row decides first. direct_io and pinned were true on every arm, and no arm was void. Round 1 of sequence 2 ran through one stretch, about 16:04-16:07:30, in which both drives read 1.75-2.23 GB/s per 10-s bin against 2.5-4.1 outside it. The cause is not established. The battery was charging (68% to 84%) throughout, unlike in attempt 3's valid arms. Attempt 3 (gate_* files) stays in the record as measured, labelled, and does not count toward this verdict. Also recorded: pooled over both sequences, all four SINGLE first-step losses sit below all four STRIPED ones (the caches are byte-identical), noticed after the fact and not investigated. The pre-commit hooks added final newlines to five JSON files and trimmed trailing whitespace in two arm logs (whitespace only; git diff -w empty). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fore sequence 3 (R4) Diagnostic only, so that a disturbed arm can name its cause. Sequence 2's round 1 slowed both drives at once for about 3.5 minutes, and nothing in the stamps, the PDH samples or the event logs said who else was reading. Each box stamp (before and after every arm) now records every process's cumulative read/write bytes from one Win32_Process CIM query (ProcessId, Name, ReadTransferCount, WriteTransferCount; about 0.5 s) and the battery's charging bit (GetSystemPowerStatus BatteryFlag 0x08, with the raw flag). Each after stamp lists the 8 processes whose read bytes grew most since the same arm's before stamp. Processes are keyed by pid and name, and a process that started during the arm, or whose counter went down, counts from 0. The arm's own process has exited by the after stamp, and a process that starts and exits within the arm is in neither stamp. Any failure is recorded as process_io_error on one line and never fails the arm. Nothing that is timed or decided changes: §2, §4's bars, the round plan, the settle and the arm command are untouched. Stamps run outside the arm's process, and each now takes about 1.4-1.9 s instead of about 1 s. Record §4 gains one sentence disclosing the addition. Checked on scratch paths only, with no arm and no gate_driver.json entry. A 441.4 MB read by the test process showed as exactly 441.4 MB at the top of the list. A broken query was recorded, not raised. Synthetic pid-reuse and counter-reset cases counted from 0. ruff check and ruff format --check are clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sequence 3 (gate3_* files, driver at 2dc4145 with --run-prefix gate3) has no valid arm. The box lost AC power at 20:36:26 (Kernel-Power 105, AcOnline=false, battery full), 12 s into its first arm, gate3_r0_single, which is therefore void (record §4). It then stayed on battery through the whole 15-minute pre-arm window (29 polls, 96% to 91%), so the void arm's re-run and the five other arms never ran. No row of §2 has an input. §2 is unchanged. Attempt 3 and sequence 2 stay in the record as measured and do not count toward this verdict. The new §6.3 sits at the top of §6, and §6.1/§6.2 are kept as written. Also recorded: - The day's eight power-source events: every AC-off came at 63-100% of full capacity, every AC-on at 5-65%. What removed AC is not recorded. - The per-process capture's first real arm: no foreign process read more than 28.5 MB, and the arm's throughput did not dip (C: 3.66-4.00 GB/s per 10-s bin). - The void arm's first-step loss (11.953904) falls inside the STRIPED range, so the pooled first-step separation noted after sequence 2 does not survive the next SINGLE arm. - The driver's not_run list again omits the void arm's re-run. The pre-commit hooks added final newlines to two JSON files and trimmed trailing whitespace in the void arm's log (whitespace only; git diff -w is empty). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sequence 4 (gate4_* files, the driver at 2dc4145 with --run-prefix gate4) is the first sequence to complete: three rounds, six valid arms, no void, no pre-arm wait, AC on and charging (71% to 89%) throughout. §2 applied mechanically: - SINGLE 17.552 / 17.431 / 17.438 s: inside 15.0-21.5 s, row 1 does not fire. - direct_io and pinned true on all six arms: row 2 does not fire. - STRIPED 10.050 / 9.971 / 9.929 s: at or under 12.0 s in every round, so the SHIP row applies. Median STRIPED 9.971 s; speed-up over the same round's SINGLE 1.746x, 1.748x and 1.756x. STRIPED's implied rate is 7.003-7.088 GB/s, against 4.010-4.038 for SINGLE. Also recorded: no arm's throughput dipped (10-s bins steady; the largest foreign reader in any arm was Windows Update's MoUsoCoreWorker at 58.2 MB). The cross-arm loss differences (at most 0.017111) sit inside the same-arm spread (at most 0.017391), and the first-step losses of the two arms overlap. The striped cache is attempt 3's, verified byte-identical in §5.5. Attempt 3 and sequences 2 and 3 stay in the record as measured and do not count. §6.4 sits at the top of §6, and §6.1-§6.3 are kept as written. §5.15 is corrected: the owner confirmed the 20:36:26 AC loss was the laptop left unplugged, and the vendor battery-setting hypothesis is removed. §2 is unchanged. The pre-commit hooks added final newlines to seven JSON files and trimmed trailing whitespace in three arm logs (whitespace only; git diff -w is empty). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…flight, deepens read-ahead (R4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ince the striped runtime (R4 Task 4 follow-up) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ow, not a bare O_NOFOLLOW (R4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…before the rule (R4) two_drive_sustained.py runs the sustained two-drive probe: one long stream_probe.py process per arm (SINGLE 21, STRIPED 36 timed steps after one warm-up, --skip-events), in the gate's round order. It imports the gate driver's box stamp, pre-arm checks, power/PDH sampler and void rule unchanged, and adds a 10-s GPU sampler, a foreign-reader void condition, the per-arm inputs the record's rule reads (early/late medians, per-drive window rates, 10-s bins), its own sustain_* logs, and --summarize, which recomputes the rule from the committed files. stream_probe.py records each step's wall-clock start (started_unix), read outside the timed region, so a step can be lined up with the driver's 1-s drive counters. The step timer is unchanged. Nothing has run: only --dry-run (into a scratch folder) and a synthetic check of the rule code on fake arm files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… run Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…le module, before the rule (R4) Three files, none of which has read a cache file or run on the GPU yet: - two_drive_throttle_read.py: one arm. The R1 unbuffered read primitive (imported from two_drive_read.py, K=4 ranges per 400 MiB span, one span in flight per drive, a pinned arena per drive) turned into a fixed-time cyclic read of C:, D: or both, logging every span's start and duration. With --arm both the two drives share only a start barrier, so a slow drive slows only itself and the per-drive rates stay separable. - two_drive_throttle_rule.py: the decision rule as pure code (5-s bins from the reader's own spans and from the 1-s PDH samples, a dip = both below 3.0 GB/s, an episode = 3 consecutive dip bins, the validity checks, the four per-drive rows). --selftest runs it on synthetic arms and --calibrate runs the detector on the sustained probe's committed PDH samples (0 episodes in r0 SINGLE, 1 in r0 STRIPED, 3 in r1 STRIPED, on both drives). The selftest was mutation-checked on temp copies with 20 one-line mutants of the rule: 19 are killed (seven only after tests were added for the survivors of the first pass) and 1 is equivalent. - two_drive_throttle.py: the driver. Imports the gate's stamps, pre-arm checks, void rule and sampler and the sustained driver's GPU sampler and foreign-reader check unchanged; adds the six-arm plan (C, D, BOTH, then BOTH, D, C), a rest between arms, a metadata-only listing of the two cache folders, a commit-headroom void and --summarize. No torch import. A dry run was made with the pre-arm check not met (another session held the GPU); it wrote only to a scratch folder and listed no cache folder. Nothing here was run against the caches: another session was timing both drives. Windows only for the driver and reader; the rule module and --selftest run anywhere. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ore the run The sustained striping probe saw recurring slow stretches in both STRIPED arms, with both drives at 2.50-2.55 GB/s instead of 3.78-4.21, and none in its single-drive arm. This records the diagnostic that asks which drive does it: six 300 s arms, one process each (C: alone, D: alone, both at once, then the reverse order), cyclic unbuffered reads of the 70B caches' own files with the R1 primitive, every span logged. Rule (section 2, in code as two_drive_throttle_rule.py, harness in dcc72eb): a dip bin is a 5-s bin in which the reader's own rate and the volume's PDH rate are both below 3.0 GB/s; an episode is 3 consecutive dip bins; per drive, an episode in its ALONE arm in both rounds is THROTTLES, none alone and some in BOTH is ONLY WHEN BOTH, none anywhere is STEADY, anything else is INCONSISTENT; a failed validity condition is NO VERDICT. Every threshold is quoted from the sustained record, whose own PDH samples the detector reproduces (one stretch in round 0, three in round 1, none in the SINGLE arm). No arm has run. Results and verdict are pending; the status line says so. Estimated wall time is about 37 minutes: six arms of 300 s are 30 minutes of reading by themselves, and an arm shorter than the 285 s coverage floor is invalid by the rule. The laptop was on battery during the last dry run (2026-10-02 14:30), so the run needs AC with the lid open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Follow-up measurement: sustained load. No verdict yet, but it changes how the 1.75x should be read. The gate times three-step bursts from a rested box. I pre-registered a sustained probe: each arm reads
So on this laptop, a long training run should expect roughly 1.3-1.4x from striping, not 1.75x, A valid run needs Windows Search stopped for the window. The records will follow in a separate |
…st figure (R4) The gate's 1.75x is a three-step burst from a rested box. The sustained probes (records added on this branch, no formal verdict yet: the box slept once and Windows Search voided arms twice) saw both drives settle near 2.5 GB/s under back-to-back load, with a late-window speed-up of 1.28x and 1.41x. The docs and the changelog fragment now quote both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Design notes from the development of this PR The working notes for this branch are being removed. These are the decisions recorded there The cache on a second drive
The reader
The measurements
|
…70 (#1714) Measurement record, published as measured; the rule was committed before the run and nothing was changed afterwards. Attempt 1: no verdict (position 0 voided twice by the validity rule). Attempt 2: SINGLE drive b2 NEAR-FREE (agrees), b3 PARTIAL (disagrees at b3); STRIPED b2/b3 PARTIAL (agree). Only benchmarks/ changes; the four commits were re-parented onto main because #1536 was squash-merged.
… wall clock (#1944) `test_every_drive_stays_busy_in_steady_state` in `tests/test_disk_tier_stripe_reader.py` measured idle gaps on a wall clock and failed on a loaded macOS runner. Each read is now held open behind an event: after the consumer takes layer k, the test waits for the reader to start the reads of k+1 and k+2 while those are still held, so a reader that keeps one read in flight at a time (the #1536 defect) times out, and scheduling noise can only delay the test, not fail it. The file runs in about 1.7 s instead of about 12 s. Tests only, no changelog fragment.
Summary
On the disk tier the training step waits on the read, and one NVMe drive is the ceiling. This PR
adds
SOUP_LAYER_STREAM_STRIPE_DIRS: extra folders on OTHER NVMe drives. Decoder layeriisstored on drive
i mod N(drive 0 is the primary cache root), and the async reader keeps one layerin flight per drive.
Measured on a cold 70B-shaped NF4 store, seq 512, batch 1, RTX 5070 Laptop (8 GB) with two
Samsung PM9B1 drives: the training step went from 17.4 s to 9.97 s (1.75x), and the read rate
from about 4.0 to 7.0 GB/s. The decision rule was committed before any run; the record is
benchmarks/gate-two-drive-striping.md. Three earlier attempts gave no verdict (the box lost ACpower twice, and one sequence saw both drives slow down mid-run); they are reported in the record
as measured. That figure is from a rested box. Under sustained back-to-back load both drives
throttled and the speed-up measured 1.28-1.41x (
benchmarks/probe-rtx5070-two-drive-sustained.md,no formal verdict yet), so the docs say to plan a long run on roughly 1.3-1.4x.
With the variable unset nothing changes: one root, the configured read-ahead, and the reader's
one-drive path.
What changes
utils/stripe_roots.py(new): parses and validates the entries. Each must be absolute, anexisting directory, not a link, free of control characters, on a different volume from the
primary cache root and from every other entry, and classify as NVMe
(
training.stream_disk_kindoverrides a wrong probe); at most 6. Anything else refuses the runand names the entry. Also the placement (
layer_roots_for) and the effective read-ahead.utils/owner_only_dir.py(new): the per-model folder Soup creates inside each stripe root isowner-only (POSIX
0o700, checked on reuse; Windows: a protected DACL for the current user andSYSTEM, re-applied on reuse). A failure refuses the run and names the folder.
utils/layer_shard.py: the index records the stripe roots and each layer's root; astripe.jsonmarker per stripe folder; the stripe folder is<slug>-<hash of the primary cache path>, so two primary caches of the same model never share one; a link or junction at thatfolder is refused before any read, write or delete; changing or unsetting the list re-shards and
names the folder left behind. Nothing is ever deleted inside a stripe root.
utils/layer_stream_runtime.py,utils/async_disk_source.py: per-layer roots reach thesources; the async reader becomes a non-blocking per-drive dispatcher (one pending read and one
range pool per drive; direct I/O is decided per drive).
trainer/stream_setup.py: resolves the roots, splits the disk pre-flight across them, anddefaults
training.stream_read_aheadto drives + 1 (a configured value equal to the defaultcounts as the default). Advice to lower the depth names a value the bump does not undo.
stream_probe.py --stripe-dir, the per-arm gate driver, a cache-compare harness, thesustained-load and per-drive throttle drivers, and four records indexed in
benchmarks/README.md: the gate (gate-two-drive-striping.md), the two-drive read-ceiling probe(
probe-rtx5070-two-drive-read.md, no formal verdict), the sustained-load probe(
probe-rtx5070-two-drive-sustained.md, no verdict) and the per-drive throttle check(
probe-rtx5070-drive-throttle.md, no verdict).docs/performance-and-quantization.md.Verification
10.050 / 9.971 / 9.929 s, one drive 17.552 / 17.431 / 17.438 s; direct I/O and pinned staging
on all six arms. The striped cache is byte-identical to the one-root cache, tensor by tensor.
soup train(SmolLM2-135M-Instruct, 8 steps) with the variable set: first stripedrun, cache reuse, unset -> re-shard plus the orphan notice, and refusals for a missing folder and
for a folder on the primary's volume. The adapter was byte-identical across the three training
runs.
tests/test_disk_tier_stripe_*.py(roots, cache, sharder, runtime,reader incl. a steady-state two-drive overlap test, setup, read-ahead advice, folders, terminal
output). Full suite on Windows / Python 3.12 / RTX 5070: the only failures are three that fail
identically on a clean checkout of
main(test_issue1174_head_prefetch_after_lookup.py::... test_untied[kto], and the PRM and MoLE resume tests intest_issue1220_resume_prm_moe.py).outside the home/cwd/tmp containment); their findings are fixed on this branch.
Not covered / follow-ups
verdict (a hibernation, then another process reading more than the validity limit): in the two
complete rounds both drives throttled and the speed-up was 1.28x and 1.41x. A valid sustained
verdict is still open.
mainmerge and the review fixes; feat(stream): make NF4 fused forward checkpoint-visible (#842) #1143 changedthe runtime in that merge. The
soup trainsmoke and the suite ran on the final tree.cells.
writable only by their account.
pre-existing.