Skip to content

feat(layer-stream): stripe the disk-tier layer cache across NVMe drives - #1536

Merged
MakazhanAlpamys merged 47 commits into
mainfrom
feat/two-drive-striping
Oct 4, 2026
Merged

MakazhanAlpamys merged 47 commits into
mainfrom
feat/two-drive-striping

Conversation

@MakazhanAlpamys

@MakazhanAlpamys MakazhanAlpamys commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

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 layer i is
stored on drive i mod N (drive 0 is the primary cache root), and the async reader keeps one layer
in 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 AC
power 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, an
    existing 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_kind overrides a wrong probe); at most 6. Anything else refuses the run
    and 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 is
    owner-only (POSIX 0o700, checked on reuse; Windows: a protected DACL for the current user and
    SYSTEM, 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; a
    stripe.json marker 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 that
    folder 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 the
    sources; 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, and
    defaults training.stream_read_ahead to drives + 1 (a configured value equal to the default
    counts as the default). Advice to lower the depth names a value the bump does not undo.
  • Benchmarks: stream_probe.py --stripe-dir, the per-arm gate driver, a cache-compare harness, the
    sustained-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: docs/performance-and-quantization.md.

Verification

  • Gate: SHIP by the committed rule (striped at or under 12.0 s in every round). Striped
    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.
  • A real soup train (SmolLM2-135M-Instruct, 8 steps) with the variable set: first striped
    run, 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: nine new files 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 in test_issue1220_resume_prm_moe.py).
  • Reviews: a whole-branch review and a security review of the stripe roots (a write location
    outside the home/cwd/tmp containment); their findings are fixed on this branch.

Not covered / follow-ups

  • The gate times three-step bursts. Sustained load was measured twice without reaching a formal
    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.
  • The gate measured the branch before the last main merge and the review fixes; feat(stream): make NF4 fused forward checkpoint-visible (#842) #1143 changed
    the runtime in that merge. The soup train smoke and the suite ran on the final tree.
  • The six POSIX owner-only tests skip on Windows; they ran and passed in CI's Linux and macOS
    cells.
  • Soup restricts only its own folder; the docs ask the operator to make the stripe root itself
    writable only by their account.
  • layer streaming: a pinned staging arena can start off a 4096-byte boundary, which silently turns direct I/O off #1531 (pinned staging arenas off a 4096-byte boundary turn direct I/O off) is separate and
    pre-existing.

MakazhanAlpamys and others added 30 commits September 28, 2026 15:54
…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>
62 commits, no conflicts. Brings #842 (#1143, NF4 fused forward made
checkpoint-visible, layer_stream_runtime.py), #1368 and #1402. The gate
record (1f30d56) describes the branch before this merge.

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>
MakazhanAlpamys and others added 9 commits October 1, 2026 18:01
…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>
@MakazhanAlpamys

Copy link
Copy Markdown
Owner Author

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
continuously for about 6 minutes, rounds run back to back, and the rule was committed before any run.
Two sequences have run. Neither reached a verdict. The first ended when the laptop slept. In the
second, Windows Search (SearchIndexer.exe) read 1.3-2.4 GB during four arms, which the rule
counts as void. The numbers below are descriptive only:

arm early median (s/step) late median (s/step)
sequence 1, round 0, one drive 17.52 17.40
sequence 1, round 0, two drives 8.38 8.43
sequence 2, round 0, one drive 17.39 17.30
sequence 2, round 0, two drives 8.35 13.53
sequence 2, round 1, two drives 13.58 13.59
sequence 2, round 1, one drive 17.30 19.19
sequence 2, round 2, one drive 17.71 19.78
  • From rest, two drives are about 2.1x faster than one: 8.4 s against 17.4 s.
  • Under sustained back-to-back load, the striped pair settles at about 2.5 GB/s per drive after a
    few minutes and does not recover within an arm. Late-window speed-ups were 1.28x and 1.41x.
  • A single drive also throttles, but later and less. After about 7 minutes of continuous reading,
    C: alone drops to about 2.6 GB/s for 11-12 s roughly every 60 s.
  • Drive temperatures are not readable without elevation, so "thermal" is the likely reading, not
    a measured one.

So on this laptop, a long training run should expect roughly 1.3-1.4x from striping, not 1.75x,
until a valid sustained sequence says otherwise. The striped cache was compared tensor by tensor
after each sequence, and both copies were equal.

A valid run needs Windows Search stopped for the window. The records will follow in a separate
benchmarks PR. I can amend this PR's docs paragraph and changelog fragment to quote the sustained
range instead of the burst figure, if the maintainer prefers that before merging.

…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>
@MakazhanAlpamys MakazhanAlpamys added the ci:full Run the full CI matrix on this PR (a maintainer adds it at approval) label Oct 4, 2026
@MakazhanAlpamys MakazhanAlpamys changed the title feat(layer-stream): stripe the disk-tier layer cache across NVMe drives (1.75x on a cold 70B step) feat(layer-stream): stripe the disk-tier layer cache across NVMe drives Oct 4, 2026
@MakazhanAlpamys
MakazhanAlpamys merged commit 45b8470 into main Oct 4, 2026
26 checks passed
@MakazhanAlpamys

Copy link
Copy Markdown
Owner Author

Design notes from the development of this PR

The working notes for this branch are being removed. These are the decisions recorded there
that the code and the benchmark records do not explain by themselves.

The cache on a second drive

  1. Stripe roots are outside the home/cwd/tmp containment on purpose. Containment would
    refuse every second drive, and setting SOUP_LAYER_STREAM_STRIPE_DIRS is itself the opt-in.
    What containment gave the primary cache is enforced directly instead:

    • the per-model folder inside a stripe root is owner-only (POSIX: mode 0700 at creation,
      owner and mode checked on reuse; Windows: a protected ACL for the current account and
      SYSTEM, applied at creation and again on reuse);
    • that folder is never followed when it is a link or junction, checked before any read,
      write or delete;
    • its name carries a hash of the primary cache's path, so two primary caches of one model
      never share a folder.

    A folder that cannot be made private refuses the run and names the folder. Two consequences:
    a stripe drive whose file system has no ACLs (FAT, exFAT) is refused on Windows (this follows
    from the code; no such volume was tried), and Soup restricts only its own folder, which is
    why the docs ask the operator to keep the stripe root itself writable only by their account.

  2. The old copy of a moved layer is deleted only after the new index is written. The index
    is the cache's commit point: every file it names must exist at every instant, so an
    interrupted re-shard leaves the old index consistent. A copy that then cannot be deleted is
    a warning, not a failure, because the shard is already committed.

  3. Cache-miss reasons print a stripe path as plain text. Only a file's base name in the
    primary root keeps repr. repr doubles every Windows backslash, and the full path has to
    stay readable in the message.

The reader

  1. AsyncDiskSource._read_layer dispatches and returns. It is still the one call per layer
    read, but it hands the ranges to that drive's workers instead of blocking until they land.
    Code that brackets the call times the dispatch; the warm-stage harness waits on the outcomes
    for that reason and refuses a striped source.
  2. With one drive the range workers keep their old thread names (soup-layer-range-<n>),
    because existing tests match them. With several drives each pool is named per drive, so a
    stack dump shows which drive a worker serves.
  3. The steady-state test gives the consumer 50 ms of work per layer on purpose. With a
    consumer that does no work the drives run in phase, and no threshold separates this reader
    from one that reads in batches. A separate posed-state test covers the exclude argument of
    _claim_slot, which no timed walk reaches.
  4. The pinned two-drive path has no CI test. Two reads in flight while a staging slot's copy
    to the GPU is still draining needs a GPU, and CI has none. The gate ran that path on the
    70B-shaped store.

The measurements

  1. The gate passes --read-ahead 3 for the striped arm explicitly (2 for the single-drive
    arm). stream_probe builds the model directly and does not go through the setup code that
    raises the default depth to drives + 1. The arms therefore differ in depth; section 7 of the
    gate record says why that is expected to matter little and that no single-drive arm at
    depth 3 was run.
  2. A verdict is read from one complete six-arm sequence. Arms of different sequences are
    never pooled, since that would pick rounds after seeing them. That is why the record reports
    three sequences without a verdict beside the one that shipped. --run-prefix exists so that
    a new sequence does not overwrite files an earlier part of the record cites.
  3. The gate measured the branch before the last merge of main (which brought feat(stream): make NF4 fused forward checkpoint-visible (#842) #1143, a
    change to the streaming runtime) and before the review fixes. The soup train check and the
    full test suite ran after both.
  4. The eight commits of the two-drive read probe are on this branch because
    utils/stripe_roots.py cites that record: evidence that code cites travels with the code.
  5. The docs say striping "changes the speed, not the result", not that training is
    bit-identical.
    The gate showed that GPU losses are not bit-identical from one process to
    the next even within one arm, while the cache bytes are identical (compared tensor by
    tensor). So the claim is about the bytes.

MakazhanAlpamys added a commit that referenced this pull request Oct 7, 2026
…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.
@MakazhanAlpamys
MakazhanAlpamys deleted the feat/two-drive-striping branch October 12, 2026 14:32
MakazhanAlpamys added a commit that referenced this pull request Oct 15, 2026
… 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:full Run the full CI matrix on this PR (a maintainer adds it at approval)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant