Skip to content

fix(layer-stream): prefetch embed_tokens at the backward tail, not the next step's prime (#975) - #1178

Merged
MakazhanAlpamys merged 8 commits into
MakazhanAlpamys:mainfrom
VanshSharmaPES:fix/975-embed-backward-tail-prefetch
Sep 27, 2026
Merged

MakazhanAlpamys merged 8 commits into
MakazhanAlpamys:mainfrom
VanshSharmaPES:fix/975-embed-backward-tail-prefetch

Conversation

@VanshSharmaPES

@VanshSharmaPES VanshSharmaPES commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Draft. On @MakazhanAlpamys's RTX 5070 run this removes about 19-23 ms of embedding wait per step (about 22 ms down to about 0.15 ms on the disk tier), on a fixture with a 501 MiB shared slot. That is still only about 0.1% of a cold 70B step, so this is latency hygiene rather than a throughput fix. This implements option (b) from #1174.

What I changed

LargeLayerBufferPool holds embed_tokens and an untied lm_head in one shared slot. _prime() (the forward-pre-hook) reloaded embed_tokens right as the next step's forward started, a full head-sized H2D copy with nothing to overlap it against (#975).

I made StreamPrefetcher also fire that same load at the tail of the backward pass, when the checkpoint recompute for decoder layer 0 confirms the step's backward walk is done. _prime() keeps its unconditional load as a fallback (step 0, and any forward that follows a backward that never reached layer 0); from the second step on it is a same-owner no-op.

Why the refill is safe

Two separate guards, and only the second protects the GPU:

  • CPU side: lm_head's backward Node sits between the loss and every decoder layer, so the autograd engine has already dispatched it, and the saved-tensor version check happens at dispatch. The embedding's own backward reads indices, never the weight values, so it never checks this slot's version.
  • GPU side: when the CPU reaches layer 0, the head's backward GEMM can still be queued on the compute stream, reading the slot. What orders the refill after it is stream.wait_stream(torch.cuda.current_stream()) in LargeLayerBufferPool.load_async. Without it the refill overwrites bytes the queued backward has not read, and the gradients come out silently wrong. That wait is now the only thing protecting this refill point, and a GPU-only test pins it.

Measured (by @MakazhanAlpamys, RTX 5070 Laptop, not by me)

On an untied Llama with hidden 2048, 8 layers, vocab 128256 (a 501 MiB slot), decoder NF4, seq 512, batch 1:

configuration embed wait() median, before to after step median, before to after
disk tier, sync every step 22.22 to 0.15 ms 306.6 to 291.6 ms
disk tier, no per-step sync 22.67 to 0.19 ms 316.9 to 296.3 ms
RAM tier (pinned), sync every step 18.57 to 0.13 ms 257.7 to 242.4 ms

The embedding wait goes to essentially zero in every configuration. The 2-6.5% step-time change is real but close to that fixture's noise floor (identical code differs by about 6 ms between labels). The head's forward-tail wait (10.6-14.6 ms) and the decoder waits are unchanged. Option (c) was not measured.

Their correctness runs: untied SFT is bit-identical to main on CPU fp32 and CUDA bf16; untied DPO and KTO with #1061 are bit-identical to #1061 alone and to resident.

What to know

  • Preference losses get part of the benefit. The prefetch fires once per backward, so DPO's no-grad reference forward still pays a real embedding load at _prime, and so do KTO's extra forwards: 1 of 2 embedding waits removed per DPO step, 1 of 4 per KTO step.
  • One extra load per train(). Steady state is 2 large-layer loads per step in both trees, but the last backward's prefetch is never consumed by a later forward, so main makes 4 loads over 2 steps and this makes 5.
  • Tied checkpoints have no shared slot, so the callback does nothing for them.

What I verified

  • ruff check clean.
  • On CPU (pytest -k "stream or 1049 or 975 or docs or changelog"): 602 passed, 35 skipped, 0 failed, after merging main.
  • tests/test_issue975_backward_tail_embed_prefetch.py: the backward-tail fires, the slot already holds embed_tokens after step 1, 2 steps complete with no autograd error, and the real-load count is 5.
  • The changelog fragment and the docs sentence are in.

What I have not run

The GPU-only test for the wait_stream guard (test_refill_waits_for_a_read_still_queued_on_the_compute_stream). My torch is a CPU build, so it skips on my machine. It follows the version @MakazhanAlpamys verified on the 5070: 5 of 5 pass with the guard, 5 of 5 fail with it removed.

…e next step's prime (MakazhanAlpamys#975)

LargeLayerBufferPool holds embed_tokens and an untied lm_head in one
shared slot. _prime() reloaded embed_tokens right as the next step's
forward started, paying a full head-sized H2D copy with nothing to
overlap it against (~33ms on the record in MakazhanAlpamys#975 for a synthetic
70B-shaped fixture).

I made StreamPrefetcher also fire that same load at the tail of the
backward pass, once the checkpoint recompute for decoder layer 0
confirms lm_head's own backward Node has already been dispatched (the
autograd engine reaches it first, since it sits between the loss and
every decoder layer) — so refilling the slot here cannot invalidate a
saved-tensor version check that already ran. _prime() keeps its
unconditional load as a fallback for step 0 and any forward that never
reaches layer 0's backward (eval); on the hot path it is now a
same-owner no-op.

I verified this on CPU: the existing streaming suite (486 tests) is
unaffected, and I added a new test file confirming the backward-tail
fires, the slot already holds embed_tokens by the time prime() asks
for it from the second step on, and 2 training steps complete without
an autograd version error (the same failure class MakazhanAlpamys#1049 hits for a
different reason). I also confirmed the DPO/KTO untied-preference
version error from MakazhanAlpamys#1049 reproduces identically with and without this
change, so it is pre-existing and not something I introduced here.

I have not verified whether this actually reduces the measured stall
from MakazhanAlpamys#975. That needs the wait() bracket in layer0_wait.py run on
hardware that can load the cold-70B fixture (an RTX 5070 or similar);
I built and tested this on a 4GB card that cannot.
@VanshSharmaPES
VanshSharmaPES force-pushed the fix/975-embed-backward-tail-prefetch branch from 9fc8ea5 to 3dcd7e7 Compare September 23, 2026 11:22
…tching layer streaming's own CPU-only convention

I pinned device="cpu" in _build_untied_wrapper for exact float32
arithmetic, but on a macOS CI runner where MPS is the accelerator that
produces a device mismatch (RuntimeError: Passed CPU tensor to MPS op)
in every test that actually runs a training step, unrelated to the fix
itself. test_v07204.py already guards every CPU-pinned streaming test
with skipif(_mps_is_the_accelerator()), which I missed when I wrote
this file — I'm applying the same guard here at the class level, since
all four tests build through the same CPU-pinned wrapper.

Confirmed against the failing CI run: the one test that doesn't call
train() (test_untied_fixture_actually_shares_one_slot) passed on
macOS; the three that do all failed with the same MPS/CPU mismatch,
which is exactly what this guard is meant to skip.

@MakazhanAlpamys MakazhanAlpamys left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Advisory review of the draft at 5223a0de.

Thank you for turning (b) into code so quickly. I ran the measurement you could not, and the result is good news for (b). Everything below was run on the RTX 5070 Laptop GPU (8 GB, driver 616.92, Windows 11, Python 3.12.10, torch 2.14.0+cu130, bitsandbytes 0.50.2, transformers 5.17.0, peft 0.20.0, trl 0.29.1), on your head merged with current main (it merges cleanly, and cleanly with #1061's head too).

What holds

Correctness. I ran real trainer.train() steps (4 per arm, identical LoRA init loaded into every arm, dropout 0) and compared the loss tensor of every step, every LoRA gradient at on_pre_optimizer_step, and the final adapter weights with torch.equal:

  • CPU fp32: untied SFT on this PR is bit-identical to main and to a resident run (48 of 48 gradient tensors). The same holds for untied SFT at gradient_accumulation_steps: 2, for untied ORPO and SimPO (also equal to resident), and for tied SFT and tied DPO. Tied checkpoints have no large pool, so the new callback is None there.
  • Untied DPO and KTO crash the same way on main and on this PR: #1049, in step 1's backward, before your code runs. You were right about that. With #1061 merged in, untied DPO and KTO on #1061 + this PR are bit-identical to #1061 alone and to resident.
  • CUDA bf16: untied SFT, two runs of main and two of this PR, all four bit-identical. Untied DPO on #1061 + this PR is bit-identical to #1061 alone, and tied SFT is unchanged.

Scheduling. I traced every large-layer load. The prefetch fires exactly once per backward. From step 2 on, _prime's load is a same-owner no-op. With gradient_accumulation_steps: 2 it fires at the tail of every micro-batch's backward, which is right, because every micro-batch's forward starts with the lookup. A no-grad forward never fires it, and an eval forward after training finds the embedding already in the slot.

Tests vs mutations (CPU, as CI runs them): reverting the wiring at layer_stream_runtime.py:2520 fails 2 of your 4 tests. So do deleting the call at :1055 and dropping the direction check at :1050. The last one is worth knowing about: the prefetch then fires in the forward, and the flag silently blocks the backward one. Removing the flag reset in prime() fails 1. Firing the refill before the head's backward (a grad hook on the head output) fails 3, with the modified by an inplace operation error, so test_two_steps... does its job. Calling the prefetch twice, or dropping the once-per-pass flag, survives. Both are equivalent mutants, because load_async's owner check makes a repeat a no-op. Against main your file gives 2 failed / 2 passed.

The measurement #1174 asked for (arms a and b). The cold synthetic-70B fixture from #975 does not fit what I could run here, so I built a smaller one with the committed synth_checkpoint.py: untied Llama, hidden 2048, 8 layers, vocab 128256. That makes the shared slot 501.0 MiB bf16. Decoder NF4, seq 512, batch 1, LoRA r8 on q/k/v/o, PagedAdamW8bit. I used the committed stream_probe.Instruments with layer0_wait's labelling, so these are the same wait() brackets as gate-971 §8.

Both arms ran in one process. Arm A is your tree with backward_tail_prefetch set to None, which is exactly the old prefetcher, and a run on unmodified main confirms that. Arm B is your change. The schedule: 3 warm-up steps discarded, then five interleaved A/B blocks, each with 1 transition step discarded and 2 measured. That gives 10 measured steps per arm.

configuration embed wait() median, A → B step median, A → B
disk tier (AsyncDiskSource, read_ahead 2), sync every step 22.22 → 0.15 ms 306.6 → 291.6 ms
same, repeated 22.22 → 0.16 ms 302.8 → 296.9 ms
disk tier, no per-step sync 22.67 → 0.19 ms 316.9 → 296.3 ms
RAM tier (pinned), sync every step 18.57 → 0.13 ms 257.7 → 242.4 ms
RAM tier, no per-step sync 18.72 → 0.12 ms 252.1 → 242.3 ms
control: unmodified main, both labels 22.31 / 22.61 ms 310.6 / 316.7 ms

The embedding's wait() goes to essentially zero in every configuration. On the disk tier the step's total wait drops from 38.08 to 15.33 ms. Step time falls by 5.9–20.6 ms (2–6.5%) in all five runs. Identical code differs by about 6 ms between labels, though, so the step-time gain is real but close to the noise floor on this fixture. The head's forward-tail wait (10.6–14.6 ms) and the decoder waits (~1.0 ms) are unchanged, as expected. I did not measure (c).

Blocking

  1. Three of the four new tests fail on any machine with a CUDA GPU. _build_untied_wrapper pins device="cpu" (test file line 45) and then calls trainer.train(). TrainingArguments still picks CUDA, so the batches land on cuda:0 and the lookup fails with Expected all tensors to be on the same device, but got index is on cuda:0, different from other tensors on cpu. On the 5070 the file gives 3 failed / 1 passed. CI is green only because its runners have no GPU. None of these tests compares floats, so the line-55 reason for pinning the device does not apply. Fix: device="cuda" if cuda_available() else "cpu", which is what _build_streamed_wrapper and #1061's train-step test do. With that one change, all 4 pass on the 5070, and reverting the wiring still fails 2 of them there.

  2. The safety comment names the wrong guard, and the guard that actually protects the new refill point is untested. The comment at :1037-1048 rests on the version counter: "dispatch, not GPU completion, is what the saved-tensor version counter checks against". That is true of the CPU-side check. It says nothing about the GPU reading the slot. When the CPU reaches layer 0, the head's backward GEMM can still be queued on the compute stream. What orders the refill after it is stream.wait_stream(torch.cuda.current_stream()) in LargeLayerBufferPool.load_async (:951). When layer 0 is reloaded during the backward, the same call at :860 is also queued earlier on the same copy stream.

    I checked this by execution. The setup: an untied 2-layer fixture with 2 buffers, so layer 0 is not reloaded in the backward; a torch.cuda._sleep(2e8) enqueued from a grad hook on the head's output, so the compute stream lags the CPU inside the backward (the sleep was still running when your prefetch fired, in steps 2–4); 4 real trainer steps; bf16. The results:

    • this PR with the lag is bit-identical to main
    • main with the lag and :951's wait removed is still bit-identical, because on main nothing refills the slot inside the backward
    • this PR with the lag and :951's wait removed diverges from step 2 on: layer-0 v_proj lora_A max |Δ| 0.068, q_proj lora_B 1.3e-3, and the losses follow

    So after this PR, :951 is the only thing standing between the new refill point and silently wrong gradients on a GPU. Please:

    • rewrite the comment to say that.
    • add a CUDA-only test (skipif without CUDA) built on that setup. It should compare against the same run with backward_tail_prefetch=None, and it must fail when :951's wait is removed. It will only run on machines with a GPU, but it is the one test that pins the hazard this change introduces.
  3. The load-count test asserts the right number for the wrong reason. Lines 137–139 say "5, not 6: one fewer than two full steps … would cost". Line 126 says "without the backward-tail prefetch turning into an extra, unplanned load". On main, the same 2 steps make 4 real large-layer loads, not 6: the test fails there with assert 4 == 5. Both trees make 2 loads per step in steady state. This PR adds exactly one per train(), the last backward's prefetch, which nothing uses unless a forward follows. I measured 8 → 9 at 4 steps, and 16 → 17 at GA 2 and for DPO. Please rewrite the comment to say main = 4, PR = 5, and why.

  4. Before this leaves draft:

    • add a changelog fragment, changelog.d/<current baseline>/1178.changed.md, crediting (#975 by @VanshSharmaPES in #1178).
    • add one sentence to docs/performance-and-quantization.md:354, which still says the embedding "is loaded for the model input". It is now reloaded at the tail of the previous step's backward.
    • state the measured magnitude at the top of the description, as #975's acceptance criteria ask: about 19–23 ms of embedding wait removed per step on this fixture, still about 0.1% of a cold 70B step.

Non-blocking

  • Preference losses get part of the benefit. The prefetch fires once per backward. DPO's no-grad reference forward still pays a real embedding load at _prime, and so do KTO's three extra forwards. That is 1 of 2 embedding waits removed per DPO step, and 1 of 4 per KTO step. Worth one line in the description.
  • What is left for (c). With (b) in, the remaining large-layer wait is the head's forward-tail load, 10.6–14.6 ms per step here. That is now what (c)'s second slot would buy, for 501 MiB here and about 1 GiB on an 8B-class head. The embedding's bytes in the slot are dead once the lookup has run, so the head load could also move earlier in the forward, at zero VRAM. I would measure that before (c). This belongs on #1174, not in this PR.
  • The last step's copy is the only load that nothing ever waits on. It is harmless while the slot outlives it. If you want every load to be consumed again, synchronize the copy stream in StreamRuntime.close().
  • backward_tail_prefetched is redundant. Layer 0 is recomputed once per backward. Keeping it as documentation is fine.
  • _prime's comment. "a frozen embedding with no LoRA reaching it" would be clearer as "a backward that never reaches layer 0". That is the actual condition under which the fallback is the only load.

Nits

  • Line 101 calls large_pool.load_async(embed_key, runtime.source, None). That re-tests the pool's existing owner check, not _prime. The load-count test already covers _prime.
  • Once #1061 lands, _build_streamed_wrapper(..., tie=False) can replace the local helper. The two PRs merge cleanly, and together they are bit-exact for DPO and KTO.
  • For whoever re-runs layer0_wait.py for #975: after this change, the embedding's copy bracket is recorded in the previous step's copies, and the last step carries one extra. Compare stalls with gate-971 §8, not the copy lists.

…celerator, not a hardcoded CPU

I pinned device="cpu" in _build_untied_wrapper, but on a machine with a
CUDA GPU TrainingArguments still picks CUDA, so the batches land on
cuda:0 and the embedding lookup fails on a device mismatch. Three of the
four tests failed that way on an RTX 5070; CI only passed because its
runners have no GPU. None of these tests compares floats, so the exact
CPU arithmetic I was pinning for buys nothing here. I now pick
"cuda" if cuda_available() else "cpu", the same way
_build_streamed_wrapper does. The MPS skip stays, since without CUDA the
helper still falls back to the CPU, and I reworded its comment to say
that instead of the float-pinning reason.
…refill after the head's backward

My comment in StreamPrefetcher.advance justified the backward-tail
refill with the saved-tensor version counter. That is true of the
CPU-side check, but it says nothing about the GPU: when the CPU reaches
layer 0 the head's backward GEMM can still be queued on the compute
stream, reading the shared slot. What orders the refill after it is
stream.wait_stream(torch.cuda.current_stream()) in
LargeLayerBufferPool.load_async. The comment now names both guards and
says the second is the one protecting the GPU, and that removing it
gives silently wrong gradients from the second step on. Comment only,
no behaviour change.
@VanshSharmaPES

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review and for running the measurement I couldn't. I've pushed two commits so far and am working through the rest.

fa95add: blocking item 1. _build_untied_wrapper now picks "cuda" if cuda_available() else "cpu" instead of hardcoding "cpu", the same way _build_streamed_wrapper does, so the batches and the model land on the same device. I kept the MPS skip, because without CUDA the helper still falls back to the CPU and a Mac runner hits the same mismatch that broke macOS CI. I reworded its comment to say that instead of the float-pinning reason, which you're right doesn't apply here. All 4 tests pass on CPU for me, but I can't run the CUDA branch on a 4 GB card, so that half rests on your 5070 run.

2be7473: the comment half of blocking item 2. I rewrote the comment in advance() so it names both guards and says which one protects the GPU. The version counter covers the CPU-side check. stream.wait_stream(torch.cuda.current_stream()) in LargeLayerBufferPool.load_async is what orders the refill after the head's backward GEMM, and it says that removing it gives silently wrong gradients from step 2 on. Comment only, no behaviour change.

Still open: the CUDA-only test that fails when that wait is removed (I'll write it from your setup, but I can't run it here, so I'll say so in the PR), the load-count comment (main = 4, this PR = 5), the _prime comment wording and the line-101 assertion, the changelog fragment, the docs sentence, and the measured magnitude at the top of the description.

@MakazhanAlpamys MakazhanAlpamys left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Advisory review of the draft at 2be74739.

Thank you for turning both pushes around quickly. I re-ran everything on your head merged with main at dd9a09fd, on the same RTX 5070 as last time, and with CUDA hidden, which is what CI sees. While I was reviewing, #1031 landed in layer_stream_runtime.py. Your branch merges cleanly with it, so I repeated the test file, the mutations and the streaming suite on 1c053a8c, and everything below still holds.

Closed

Item 1: the tests on a CUDA machine. With the 5070 visible your file now gives 4 passed (41 s), and 4 passed with CUDA hidden. The mutation results are the same on both:

  • reverting the wiring at :2530 fails 2 tests;
  • deleting the call at :1065 fails 2;
  • dropping the direction check fails 2;
  • removing the flag reset in prime() fails 1;
  • firing the refill before the head's backward fails 3.

Against main the file gives 2 failed / 2 passed. The 31 streaming test files give 1253 passed, 13 skipped and 0 failed on the 5070, and 1193 passed, 73 skipped and 0 failed with CUDA hidden.

Item 2, the comment half. The new comment names the right guard, and I re-checked what it says by execution rather than by reading. The setup was the 2-layer untied fixture with 2 buffers, bf16, and a torch.cuda._sleep(1e9) enqueued from a grad hook on the head's output. The sleep makes the compute stream lag the host inside the backward; at steps 2–4 it was still running when your prefetch fired. Over 4 real trainer steps:

  • this PR is bit-identical to main in two runs: 32 of 32 LoRA gradients, every loss, and the final adapter;
  • with the wait_stream at :951 removed, both runs diverge at step 2: all 8 gradients, worst layer-0 v_proj lora_B |Δ| 0.40, and the losses follow from step 3;
  • main with the same wait removed stays bit-identical, because nothing refills the slot inside its backward.

So the comment is right that :951 is the only thing protecting the GPU.

Blocking (before this leaves draft)

  1. The CUDA-only test for :951 is still missing, and the recipe I gave you last time would be a flaky test. With the wait removed, all 4 tests pass on the 5070 as well as on CPU, so nothing detects that removal on any machine.

    The flakiness comes from host load. I first ran the lag recipe with a smaller sleep while the host was busy with another test run. In 2 of 3 lag runs the sleep had already finished at every prefetch, so the race never opened, and "wait removed" came out bit-identical to your PR. A test built that way would have passed with the guard deleted.

    A pool-level test does not depend on how fast the host is. I checked this one on the 5070: 5 of 5 pass with the guard and 5 of 5 fail with it removed, about a second each, and it held while the host was loaded:

    class _PinnedSource:
        def __init__(self, tensors):
            self.tensors = tensors
    
        def get(self, idx, key):
            return self.tensors[key]
    
    
    @pytest.mark.gpu
    def test_refill_waits_for_a_read_still_queued_on_the_compute_stream():
        import torch
    
        from soup_cli.utils.layer_stream_runtime import LargeLayerBufferPool
    
        shape = (1024, 1024)
        pool = LargeLayerBufferPool(
            {"lm_head": (shape, "float32"), "embed_tokens": (shape, "float32")},
            {"lm_head": 0, "embed_tokens": 1},
            device="cuda",
        )
        head = torch.full(shape, 1.0).pin_memory()
        source = _PinnedSource({"lm_head": head, "embed_tokens": torch.full(shape, 2.0).pin_memory()})
        copy_stream = torch.cuda.Stream()
        pool.load_async("lm_head", source, copy_stream)
        slot = pool.wait("lm_head")
        torch.cuda.synchronize()
        torch.cuda._sleep(int(1e9))  # the compute stream lags, like a queued head backward
        seen = slot.clone()  # a read of the slot queued behind it
        pool.load_async("embed_tokens", source, copy_stream)  # the #975 refill
        torch.cuda.synchronize()
        assert torch.equal(seen, head.cuda())

    The host issues the refill microseconds after the read is queued, while the sleep takes about a second. So the race is always open, and the test only passes if load_async orders its copy after the read.

    If you also want a trainer-level test, it has to assert that the race actually opened, for example by recording event.query() on the sleep when the prefetch fires. Otherwise a slow host turns it into a test that always passes.

    About your note that you can't run the CUDA side on a 4 GB card: this is not a VRAM limit. The fixture is 2 layers × hidden 64, and the test above needs about 12 MB. Your 3050 can run both, as long as your torch is a CUDA build. Check with python -c "import torch; print(torch.cuda.is_available())"; on Windows, pip installs the CPU wheel unless you pass the PyTorch index.

  2. The load-count comment (test lines 132 and 137–145) is unchanged. The assertion is right but the reasoning is not: on main the same 2 steps make 4 real loads, and the file fails there with assert 4 == 5. This PR adds exactly one load per train(), the last backward's prefetch. Please say main = 4, PR = 5, and why.

  3. Changelog, docs and description.

    • Add the changelog fragment. Since #1126 the baseline is 0.75.1, so the file is changelog.d/0.75.1/1178.changed.md, crediting (#975 by @VanshSharmaPES in #1178).
    • Add the sentence at docs/performance-and-quantization.md:354, which still says the embedding "is loaded for the model input".
    • Update the description. It still says "performance NOT measured"; the measured numbers are in my last review (embedding wait 19–23 ms → ~0.15 ms on that fixture). It also still gives only the version-counter argument, which your new comment now says does not protect the GPU.

Non-blocking

  • The items from my last review are unchanged: preference losses get part of the benefit; the head's forward-tail wait is what (c) would now buy; a copy-stream sync in close(); the redundant flag; and the _prime comment wording at :2587–2591. You listed the last one as still to do.

Nits

  • The test module's docstring (lines 6–11) still gives only the CPU-side argument, "cannot invalidate a saved-tensor version check that already ran". Please point it at the GPU guard as well.
  • The new comment says gradients come out wrong "from the second step on". That matches what I measured: step 1 never raced in any of the 7 lag runs. But the reason is that the first step's host-side overhead outlasts the lag, not anything in the code, and step 1's refill is issued the same way. I would drop the step qualifier.
  • Line 107 still re-tests the pool's existing owner check rather than _prime.

…l, fix the load-count reasoning, add changelog and docs

This works through the second review on MakazhanAlpamys#1178.

I added a pool-level GPU test for the wait_stream in
LargeLayerBufferPool.load_async. It holds the compute stream back with
torch.cuda._sleep, queues a read of the slot behind it, then issues the
refill; the read must still see the head's bytes. It does not depend on
host speed, unlike the trainer-level recipe I had planned, which would
pass with the guard deleted whenever the sleep had already finished. I
cannot run it here (my torch is a CPU build), so it is skipped on this
machine; it follows the codeowner's version, which they checked 5 of 5
with the guard and 5 of 5 failing without it.

I rewrote the load-count comment: main makes 4 real large-layer loads
over the two steps, this change makes 5, and the extra one is the last
backward's prefetch, which no later forward consumes. I dropped the
assertion that only re-tested the pool's owner check, and pointed the
module docstring at the GPU guard as well as the CPU one.

In layer_stream_runtime.py I reworded _prime's comment to "a backward
that never reached layer 0" and dropped the "from the second step on"
qualifier from the wait_stream comment.

I also added the changelog fragment and one sentence in
docs/performance-and-quantization.md saying the embedding is reloaded at
the tail of the backward pass.
@VanshSharmaPES

Copy link
Copy Markdown
Contributor Author

Thanks for the second pass, and for catching that the lag recipe I'd planned would be flaky on a busy host. I've pushed bc13d61 and a merge of main (77dc04e).

Blocking 1, the CUDA-only test. I added your pool-level test as written: the compute stream is held back with torch.cuda._sleep, a read of the slot is queued behind it, then the refill is issued, and the read must still see the head's bytes. It's @pytest.mark.gpu. I haven't run it. The torch in my venv is the CPU wheel (2.14.0+cpu, is_available() is False), so it skips here and I'm relying on your 5-of-5 and 5-of-5 results. I'll try it on my 3050 once I've installed a CUDA build. I wrote the docstring to say why the pool-level version is used and that a trainer-level one would need to assert the race really opened.

Blocking 2, the load-count comment. Rewritten: main makes 4 real loads over the two steps (the cold embed and the head at step 0, then the embed and the head at step 1), this PR makes 5, and the fifth is the last backward's prefetch, which no later forward consumes. Steady state is 2 loads per step in both. I also removed the assertion that only re-tested the pool's owner check.

Blocking 3.

  • Changelog fragment at changelog.d/0.75.1/1178.changed.md, crediting (#975 by @VanshSharmaPES in #1178). My branch predated 0.75.1, so test_repository_fragments_match_the_newest_release rejected it. That's why I merged main rather than only adding the file.
  • One sentence added at docs/performance-and-quantization.md, saying the embedding is reloaded at the tail of the step's backward.
  • I have not yet updated the PR description. I'll do that next, with your measured numbers at the top and the DPO/KTO partial-benefit line.

Nits, taken:

  • The test module's docstring now names the GPU guard as well as the CPU one.
  • Dropped "from the second step on" from the wait_stream comment, since the step qualifier reflects host overhead rather than the code.
  • Reworded _prime's comment to "a forward that follows a backward that never reached layer 0".

Checks on the merged tree (CPU): ruff is clean, the changelog validator passes, and the streaming, docs and changelog suites give 602 passed, 35 skipped, 0 failed. Merging main brought in #1061's fix, which is why tests/test_issue1049_untied_streamed_preference.py is now there.

Not taken: the copy-stream sync in StreamRuntime.close() and removing backward_tail_prefetched. Both are fine to leave, and I'd rather not widen this PR.

@MakazhanAlpamys

Copy link
Copy Markdown
Owner

@VanshSharmaPES, thank you. Every item from the last review is addressed. Three things stand out:

  • The GPU test is the pool-level version as written, and its docstring records why a trainer-level one would have to prove the race opened. That note stops someone from swapping it for the flaky variant later.
  • The load-count comment is now right. I confirmed it by running your file against main, where it fails with assert 4 == 5.
  • Dropping the owner-check block lost no coverage. I checked that by mutation (below).

The description now leads with the measured magnitude, the fragment and the docs sentence are in, and merging main was the right way to pick up the 0.75.1 fragment baseline.

What I ran. All of it was on CPU with CUDA_VISIBLE_DEVICES=-1, importing soup_cli from the checkout:

#1141 and #1143, the other two open drafts in layer_stream_runtime.py, both merge cleanly with your branch. #1141 changes none of the test_v07204 helpers your file imports.

Before this leaves draft: one run of the GPU test, both ways. That is the only open item. The test matches the recipe line for line; only the embedding tensor got its own name. It is not deselected by default (only smoke is), so a plain pytest runs it on any CUDA machine. But nobody has run the committed file on a GPU yet. My run was CPU-only, and the five-and-five you cite was measured on the recipe before it went into your file. On a CUDA build of torch:

pytest tests/test_issue975_backward_tail_embed_prefetch.py -m gpu --no-cov -p no:cacheprovider
  1. Run it five times. It should give 1 passed each time.
  2. Comment out stream.wait_stream(torch.cuda.current_stream()) in LargeLayerBufferPool.load_async and run it five more times. It should give 1 failed each time.
  3. Restore the line and run the whole file once on the GPU. It should give 5 passed.

Please post the counts, the card and the torch build. The test needs about 12 MB, so the 3050 is plenty. On Windows, pip installs the CPU wheel unless you point it at the PyTorch CUDA index for a CUDA version your driver supports; nvidia-smi shows the highest one. If getting a CUDA build onto that card turns out to be more trouble than it is worth, say so here. The 5070 is busy today, and a run will be scheduled on it when it is free.

After that, mark it ready for review. The two non-blocking items you left, the copy-stream sync in close() and the redundant flag, are fine as they are.

CI on 77dc04e3 is still queued: all 13 required checks are pending.

@VanshSharmaPES

Copy link
Copy Markdown
Contributor Author

Here are the GPU counts you asked for, on 77dc04e.

Card and build: RTX 3050 Laptop GPU, torch 2.14.0+cu130 (a separate venv from my CPU one), driver 592.82.

  1. Guard in place, pytest tests/test_issue975_backward_tail_embed_prefetch.py -m gpu --no-cov -p no:cacheprovider, five runs: 1 passed each time (5 of 5).
  2. stream.wait_stream(torch.cuda.current_stream()) removed from LargeLayerBufferPool.load_async, five runs: 1 failed each time (5 of 5, assert False). I removed it by patching load_async at runtime with the line taken out, not by editing the file, so the tree stayed clean. It is the same removal.
  3. Guard restored, whole file on the GPU: 5 passed (the four trainer tests plus the GPU one).

Earlier I got a first mutation attempt wrong: the script raised before writing, so its three passes were against unmodified code and proved nothing. I redid it and only counted runs where the guard was really gone.

I've also opened the prototype for moving the head's load earlier as a separate draft, #1258, branched from this head as you asked. Nothing in it changes what this PR does.

@MakazhanAlpamys

Copy link
Copy Markdown
Owner

@VanshSharmaPES, thank you for getting a CUDA build onto the 3050 and running this yourself. Thank you also for saying that your first mutation attempt ran against unmodified code, instead of counting its three passes. That note is why the other counts can be trusted.

Your run closes the one item left open on 2026-09-24. The committed GPU test:

  • passed 5 of 5 times with the guard in place;
  • failed 5 of 5 times with stream.wait_stream(torch.cuda.current_stream()) removed;
  • and the whole file gave 5 passed on the GPU.

You also named the card, the torch build and the driver. Patching load_async at runtime is an acceptable way to remove the line. The five failures are what show the patch took effect, because a patch that changed nothing would have passed.

Current state, checked today:

  • All 13 required checks pass on 77dc04e3.
  • On CPU the file gives 4 passed and 1 skipped (the GPU test).
  • The branch still merges cleanly with main at afd9a917, 24 commits past your last merge.

The GPU evidence on record is your run. No GPU was available for this check, so I did not re-run it.

The next step is yours: mark it ready for review. #1258 is branched from this head, so it can only land after this one.

@VanshSharmaPES
VanshSharmaPES marked this pull request as ready for review September 26, 2026 07:53
@MakazhanAlpamys

Copy link
Copy Markdown
Owner

@VanshSharmaPES, thank you for marking this ready, and for the 3050 run that made it possible.

What is good here, specifically

  • The change is exactly option (b) and nothing more. It is one callback and one flag in StreamPrefetcher, the wiring in install_streaming, and _prime's load kept as the fallback. Nothing in async_disk_source.py changes. I checked that the fallback is still needed: deleting _prime's load fails 3 of your 4 tests with large-layer scheduler bug: slot holds None.

  • test_two_steps_complete_without_an_autograd_version_error does its job. I added a grad hook on the head's output that fires the refill before the head's backward, and 3 of the 4 tests failed, all with modified by an inplace operation.

  • The load-count claim holds exactly. I recorded the order of real loads on a 4-layer untied fixture over 2 steps. It is main's order with the embedding load moved to the end of each backward, plus the one trailing load you describe:

    main: embed L0 L1 L2 L3 head L1 L0 | embed L0 L2 L3 head L1 L0
    PR:   embed L0 L1 L2 L3 head L1 L0 embed | L0 L2 L3 head L1 L0 embed
    

One blocking item: the tests pin that the refill fires in the backward, not where

layer_stream_runtime.py:1061 is the and idx == 0 in advance(). With that line removed, the refill fires at the first recompute that walks downward instead of at layer 0. On the file's 2-layer fixture those are the same call, so all 4 tests still pass (4 passed, 1 skipped). So do the 46 test files that exercise layer streaming (1495 passed, 93 skipped, the same as without the change).

On any model with three or more layers, the two placements differ. The same 4-layer recording with that line removed gives:

embed L0 L1 L2 L3 head L1 embed L0 | L0 L2 L3 head L1 embed L0

The head-sized copy is now issued ahead of layer 0's backward load. On CUDA, that load then queues behind it on the copy stream both pools share, so the wait moves into the backward instead of going away. It also changes the order of source.get() calls, and #975 dropped its race-battery criterion on the grounds that this PR leaves that order unchanged. No test would notice either change.

A 4-layer version of your trainer test catches it. Paste it into TestBackwardTailEmbedPrefetch, after test_prime_no_op_saves_one_reload_from_the_second_step_on:

    def test_the_refill_fires_once_per_backward_at_layer_0(self, tmp_path, monkeypatch):
        """Where in the backward the refill fires, not only that it fires.

        On two layers the first recompute that walks downward IS layer 0, so a
        refill moved to the start of the backward passes every other test here.
        On a real model that would queue the whole head-sized copy in front of
        the backward's decoder copies on the shared copy stream, and change the
        order of `source.get()` calls, which #975 relies on being unchanged.
        Four layers tell the two apart.
        """
        wrapper, _, _ = _build_untied_wrapper(tmp_path, monkeypatch, n_layers=4)
        prefetcher = wrapper._stream_runtime.prefetcher
        refill = prefetcher.backward_tail_prefetch
        fired_at = []

        def _recording_refill():
            fired_at.append((prefetcher.direction, prefetcher.prev))
            refill()

        prefetcher.backward_tail_prefetch = _recording_refill
        wrapper.trainer.args.max_steps = 2
        wrapper.trainer.train()
        wrapper._close_stream_runtime()
        # (direction, layer) at each call: once per step, walking down, at layer 0.
        assert fired_at == [(-1, 0), (-1, 0)], fired_at

Here is what I got with it in your file, on CPU, with your branch merged with current main:

  • without mutation: 5 passed, 1 skipped;
  • and idx == 0 removed: 1 failed, [(-1, 2), (-1, 2)] == [(-1, 0), (-1, 0)];
  • direction check dropped: 3 failed (2 before);
  • flag reset removed from prime(): 2 failed (1 before);
  • once-per-pass flag check removed: still passes. That mutant is equivalent, as you and the maintainer found.

The method is clean under ruff check and ruff format. It also passes with #1258's four commits applied on top (5 passed, 1 skipped).

One nit

The module docstring (lines 23-25) says _build_streamed_wrapper in test_v07204.py "never passes tie through to _tiny_llama_dir". Since #1061, which your merge of main brought in, it takes tie= and passes it through (test_v07204.py:349-371). Please correct that sentence, or switch the helper to _build_streamed_wrapper(..., task="sft", tie=False).

What I ran

Everything ran on CPU with CUDA_VISIBLE_DEVICES=-1, with soup_cli imported from the checkout. I ran nothing on a GPU.

  • Your head 77dc04e3: your file gives 4 passed, 1 skipped (the GPU test).
  • Your head merged with main at 964ddc67, 43 commits ahead of your last merge. It merges cleanly, and still does with today's a7cf9c40.
    • your file plus the changelog-fragment tests: 25 passed, 1 skipped;
    • the 46 test files that exercise layer streaming: 1495 passed, 93 skipped, 0 failed;
    • ruff check: clean.
  • Your file on unmodified main: 2 failed (AttributeError: ... 'backward_tail_prefetched' and assert 4 == 5), 2 passed, 1 skipped.
  • Mutations of layer_stream_runtime.py, run against your file:
mutation result
wiring reverted (:2671) 2 failed
refill call deleted, flag kept (:1065) 2 failed
direction check dropped, or inverted (:1060) 2 failed each
flag reset removed from prime() (:1012) 1 failed
_prefetch_embed loads the head's key (:2663) 2 failed
refill also fired before the head's backward 3 failed
_prime forced to copy every step (:2740) 1 failed
_prime's fallback load deleted 3 failed
once-per-pass flag check removed (:1062) passes (equivalent)
wait_stream removed from LargeLayerBufferPool.load_async (:951) passes on CPU; only the GPU test pins it
and idx == 0 removed (:1061) passes: the blocking item above

On the GPU side, your 3050 run is the evidence for the wait_stream guard. LargeLayerBufferPool is byte-identical between 77dc04e3 and the merge with current main, so I am not asking for another run of it. Once the new test is in, one run of the whole file on your 3050 would confirm that the new trainer-level test passes on CUDA like the other four. It should give 6 passed:

pytest tests/test_issue975_backward_tail_embed_prefetch.py --no-cov -p no:cacheprovider

For #1258, once this lands

If this PR is squash-merged, a plain git rebase origin/main on #1258 stops at this PR's first commit, 3dcd7e70, with conflicts in layer_stream_runtime.py and the #975 test file. Merging main into #1258 conflicts in the docs paragraph and the runtime file. Replaying only #1258's own four commits is clean:

git rebase --onto origin/main 77dc04e3

I tried that on a local squash of this PR, and the runtime file and both test files came out byte-identical to 427befb0. The test above touches only the #975 file, which #1258 does not modify, so adding it does not change that.

CI: all 13 required checks pass on 77dc04e3. They will need to run again on the head that adds the test.

…ot only that it fires

On a 2-layer fixture the first backward recompute that walks downward IS
layer 0, so removing the 'idx == 0' check from the trigger passed every
test here: the refill still fired once per backward, just potentially at
the wrong layer. On a real model that would queue the head-sized copy
ahead of the backward's decoder copies on the shared copy stream instead
of after them, and it would change the order of source.get() calls, which
MakazhanAlpamys#975 relies on staying unchanged.

I added a 4-layer test that records (direction, layer) at every call and
asserts it fires walking down, at layer 0, once per step. Removing the
idx == 0 check now fails it. I checked two more of the previously-reported
mutations against the new test and got the same results as before: the
once-per-pass flag check is still an equivalent mutant, and removing the
flag reset in prime() still fails 2.

I also corrected the module docstring: _build_streamed_wrapper has taken
tie since MakazhanAlpamys#1061, and this file predates that.
VanshSharmaPES added a commit to VanshSharmaPES/Soup that referenced this pull request Sep 27, 2026
…'s load is issued (MakazhanAlpamys#1174)

I wrote this so the 5070 run for MakazhanAlpamys#1258 is one command. It flips
StreamPrefetcher.head_prefetch_layer between blocks in one process on one
model, using the committed stream_probe.Instruments and layer0_wait's
labelling, so the wait() brackets are the same ones as gate-971 section 8.
The number to judge an arm by is the step's total wait, with per-group
medians (embedding, head, layer 0, other decoder layers) to show where a
change went. It covers both tiers, --read-ahead, and a preference-shaped
step (a no-grad forward first) for the disk-tier case at read-ahead 3. The
arm order rotates each block. On a tree without head_prefetch_layer it exits
with a message instead of comparing an arm with itself, which I checked
against MakazhanAlpamys#1178's head.

I ran it on an RTX 3050 with a 4-layer untied 8B-shaped fixture on the RAM
tier and on the disk tier with the preference step at read-ahead 3, to check
that it completes and that the switch takes effect. Those are 4 samples per
arm on a different card, so I am not reporting them as a result.
@VanshSharmaPES

Copy link
Copy Markdown
Contributor Author

Thanks for the exact repro on two layers, and for the test to tell the two placements apart. Pushed a5cc33c and a merge of main (301a80c).

The blocking item. I added your test verbatim, on a 4-layer fixture: it records (direction, layer) at every backward-tail call and asserts it fires walking down, at layer 0, once per step. With and idx == 0 removed from the trigger it now fails 1, [(-1, 2), (-1, 2)] != [(-1, 0), (-1, 0)]. I also re-checked two of the other mutations against the updated file: the once-per-pass flag check is still an equivalent mutant (passes), and removing the flag reset in prime() still fails 2 — both match your counts.

The nit. The module docstring no longer claims _build_streamed_wrapper never passes tie through; it's said since #1061 that it predates that and keeps its own helper.

What I ran, on 301a80c:

  • ruff check and ruff format --check both clean.
  • CPU: the file gives 26 passed, 1 skipped (with the changelog-fragment tests included, matching your count).
  • pytest -k stream: 531 passed, 35 skipped, 0 failed.
  • Merged main at a7cf9c40, matching what you tested against; it merged cleanly.
  • GPU (RTX 3050, torch 2.14.0+cu130): 6 passed, both before and after the merge — the run you asked for.

For #1258: once this is squash-merged, I'll rebase with git rebase --onto origin/main <this PR's squash commit> as you laid out, rather than a plain rebase or a merge of main.

@MakazhanAlpamys
MakazhanAlpamys merged commit 20ab466 into MakazhanAlpamys:main Sep 27, 2026
19 checks passed
@MakazhanAlpamys

Copy link
Copy Markdown
Owner

Merged as 20ab4668. Thank you, @VanshSharmaPES: you traced the stall to the shared slot rather than to the disk-read ordering the issue first blamed, then committed a GPU test for the one guard that keeps the new refill safe and ran it both ways on your own 3050. On CPU, with your branch merged with main, your file gives 5 passed and 1 skipped (the GPU test). 10 of 12 mutations of layer_stream_runtime.py fail it; the two survivors are an equivalent flag check and the GPU-only wait_stream. The 47 streaming test files give 1529 passed, 93 skipped, 0 failed.

MakazhanAlpamys added a commit that referenced this pull request Sep 27, 2026
…s and yi111

#1103 (@umran666), #1281 (@Blizzeq), #1178 (@VanshSharmaPES),
#1117 (@Srinivasan8888), #1257 (@wanjinhao1, first merge), #1284
(@Yi-111-a, first merge), #1304 (@kok-o), #1115 and #1144 (@Amix29),
#1077 (@HarshRajSinghania). Display names from `gh api users/<login>`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
VanshSharmaPES added a commit to VanshSharmaPES/Soup that referenced this pull request Sep 27, 2026
…ookup (MakazhanAlpamys#1174 part 2)

This is the zero-VRAM alternative to a second slot (option (c) on MakazhanAlpamys#1174), to be
measured before it. With MakazhanAlpamys#1178 the embedding is reloaded at the tail of the previous backward,
so what is left of the large-layer wait is the head's forward-tail load.
It is issued from StreamPrefetcher.advance() at the last decoder layer,
before that layer's functional_call, so the copy hides behind one layer of
compute. The embedding's bytes in the shared slot are dead once the lookup
has run, and the lookup precedes layer 0's advance, so I added
head_prefetch_layer and default it to 0, the earliest point. None restores
the last-layer timing, and it is a plain attribute, so one process can run
both timings.

The refill is ordered after the queued embedding lookup by the wait_stream
in LargeLayerBufferPool.load_async, the same guard MakazhanAlpamys#1178 relies on. I added
a pool-level GPU test where the queued read is the embedding lookup.

I checked on CPU and on an RTX 3050 that the two timings are bit-identical
for untied sft (also at gradient_accumulation_steps 2), orpo, simpo, dpo and
kto and for tied sft, that the real load sequence and count are the same in
both, and that an eval forward followed by a training step behaves the same.

I have not measured whether this is faster. That needs the wait() bracket on
a GPU, judged by the step's total wait on both the RAM and the disk tier.
Two things it may not do: the head's copy queues ahead of the next decoder
copy on the single copy stream, and on the disk tier a head get() mid-forward
replans the reader's lookahead. Both are unmeasured.
VanshSharmaPES added a commit to VanshSharmaPES/Soup that referenced this pull request Sep 27, 2026
…'s load is issued (MakazhanAlpamys#1174)

I wrote this so the 5070 run for MakazhanAlpamys#1258 is one command. It flips
StreamPrefetcher.head_prefetch_layer between blocks in one process on one
model, using the committed stream_probe.Instruments and layer0_wait's
labelling, so the wait() brackets are the same ones as gate-971 section 8.
The number to judge an arm by is the step's total wait, with per-group
medians (embedding, head, layer 0, other decoder layers) to show where a
change went. It covers both tiers, --read-ahead, and a preference-shaped
step (a no-grad forward first) for the disk-tier case at read-ahead 3. The
arm order rotates each block. On a tree without head_prefetch_layer it exits
with a message instead of comparing an arm with itself, which I checked
against MakazhanAlpamys#1178's head.

I ran it on an RTX 3050 with a 4-layer untied 8B-shaped fixture on the RAM
tier and on the disk tier with the preference step at read-ahead 3, to check
that it completes and that the switch takes effect. Those are 4 samples per
arm on a different card, so I am not reporting them as a result.
MakazhanAlpamys added a commit that referenced this pull request Sep 28, 2026
) (#1313)

`data.val_split` (default 0.1) withheld that share of the rows on every run. Every trainer wrapper then passed those rows on as `eval_dataset`, but none set `eval_strategy`, whose Hugging Face default is "no", so `Trainer.evaluate()` never ran. The held-out rows were used for nothing, and the validation-loss sinks from #713 (the Val loss row, the tracker's `val_loss` and `TrainEvent.val_loss`) never received a value on the transformers backend.

A shared helper, `training_eval_kwargs` in the new `utils/eval_schedule.py`, is threaded through all 16 wrappers that receive a split: sft, pretrain, dpo, ipo, kto, orpo, simpo, bco, grpo, distill, classifier, embedding, reward_model, prm, moe_lora_routing and asr. Each evaluates a non-empty split at the end of every epoch, or every `training.eval_steps` optimizer steps. `training.eval_steps` is a new field; it must be an int of at least 1, and a YAML bool is refused. The evaluation runs at the resolved per-device train batch, not Hugging Face's 8. The embedding trainer gains a `prediction_step`, because the default one crashed on its anchor/positive batches.

The generation-based tasks (grpo, ppo, online_dpo) no longer withhold rows by default. `soup train` (including `--dry-run`), `soup sweep` and `soup cost` use every row and print a one-line note.

grpo holds the split out and evaluates it only when `training.eval_steps` is set:
- It evaluates at the largest multiple of `num_generations` that fits in the train batch, because TRL refuses an eval batch that splits a group of completions; a train batch of 3 with 2 generations used to raise at setup.
- Its evaluation rewards are kept out of the reward-hack detector, the mitigation controller and the echo-trap detector: the signal buffer they share is paused for the whole `trainer.evaluate` call.
- The docs say what grpo records as `eval_loss`: TRL's policy objective on the held-out completions, not a likelihood. The held-out reward is `eval_reward`.

`training.eval_steps` is refused at config load on ppo, online_dpo and unlearn, on `backend: mlx`, and with `val_split: 0` on local files or remote URIs. MLX is otherwise unchanged.

What users notice: runs now evaluate the held-out split at the end of every epoch and show a Val loss, which costs a few percent more time. grpo, ppo and online_dpo configs now train on every row instead of 90%, so their results are not directly comparable across this change.

Verified:
- The first CI run failed in all nine test cells, and both causes were in this PR's own tests. A subprocess `.stdout` assertion tripped the #1068 ANSI guard. The layer-streaming evaluation test built on CPU while transformers put the model on MPS on the macOS runners. Both are fixed: the assertion goes through `_plain`, and the streaming test now runs only where transformers' default device is the CPU its wrapper streams on. That is test_v07200.py's MPS guard, extended to a visible CUDA card, with both edges pinned by a test.
- The second CI run failed in every test cell on one assertion, #833's guard against a private CUDA probe in a test
  module: the device helper called `torch.cuda.is_available()`. It now reads the suite's shared `conftest.cuda_available`,
  and the condition test pins that probe; making the helper ignore CUDA fails the condition test.
- The six `soup train --dry-run` count tests read the real card, so on a dev box with a GPU the hardware-fit gate
  refused before the counts printed (green on CPU CI). They now stub the two probes the way a CPU runner answers them,
  as the #1212 tests do: 58 passed with an RTX 5070 visible, where 6 failed before.
- A new test pins the remote-URI refusal.
- On the branch rebased onto main with #1316 and #1281:
  - the #1223 files, the ANSI guard and 61 other neighbour files: 5,611 passed, 14 skipped, 0 failed;
  - `test_v05311.py` in its own process: 89 passed;
  - the MLX files in their own process: 52 passed.
- Merged with main after #1178, the streaming, snapshot and #1223 files: 681 passed.
- Without the fix, the three original test files give 102 failed / 12 passed on main. With the grpo-isolation and whole-group commits reverted they give 24 failed / 90 passed, including the leak itself: under TRL generation reuse the detector read the held-out rewards (40, 40, 40, 40) instead of the training rewards (7, 7, 4, 4).
- 26 distinct targeted mutations all fail a test.
- ruff is clean.

Closes #1223.
MakazhanAlpamys pushed a commit that referenced this pull request Sep 28, 2026
…ookup (#1174) (#1258)

On an untied streamed checkpoint, `embed_tokens` and `lm_head` share one device slot. Since #1178 the embedding is reloaded at the tail of the previous backward, so what was left of the large-layer wait was the head's forward load. `StreamPrefetcher.advance()` issued that load at the last decoder layer, where it could hide behind only that one layer's compute.

The head's load is now issued from layer 0's `advance()`, right after the embedding lookup: the embedding's bytes in the slot are dead once the lookup has run.
- `StreamPrefetcher.head_prefetch_layer` defaults to 0 when there is a large pool, and `None` restores the old timing. It is a validated property (None, or an int in [0, n_layers); a bool is refused; a refused assignment keeps the old value), so a harness can switch timings between steps in one process.
- The refill is ordered after the queued lookup by the `wait_stream` in `LargeLayerBufferPool.load_async`, which a new pool-level GPU test pins.
- `benchmarks/harness/head_prefetch_ab.py` is an interleaved A/B driver with a row in the benchmarks harness table. It has an A/A control through `--arms last,7` and rotates the arm order. After every block it writes a JSON with the GPU facts (NVIDIA driver and PCIe link included), every command-line argument, and the commit of the imported `soup_cli` tree, marked `-dirty` for uncommitted changes under `src/`. `--shards` reuses an existing shard store.

What users notice: less waiting per step on untied streamed checkpoints, and nothing else. Loss, gradients and adapter weights are bit-identical between the two timings, and tied checkpoints are unchanged. On an RTX 5070 Laptop GPU (8-layer untied fixture, hidden 2048, 501 MiB shared slot, NF4, sequence 512, 10 recorded steps per arm), the median total wait per step fell:
- from 11.96 to 4.50 ms on the RAM tier (A/A spread 0.11 ms);
- from 15.83 to 1.42 ms on the disk tier (A/A spread 0.74 ms);
- from 57.29 to 29.16 ms for a preference-shaped step on the disk tier at read-ahead 3.

Every layer-0 sample was below every last-layer sample. The other decoder layers now wait longer (0.78 to 4.06 ms on the RAM tier), which gives back part of the saving, and layer 0 was the best of layers 0 to 3.

Verified:
- Per-step gradients and final adapters are bit-identical between the two timings for SFT, SFT at gradient_accumulation_steps 2, ORPO, SimPO, DPO, KTO and tied SFT, checked against a same-timing control with lora_B randomised.
- On the tree merged with main, the #975, #1174, provenance, changelog-fragment, merge-freshness and benchmarks-index tests give 112 passed, 2 skipped (the GPU tests), 0 failed.
- All 8 mutations of the new validation are killed.
- All 11 mutations of the driver's provenance code are killed, including hard-coding `git_sha` in the payload and dropping `--shards`. A CPU test now runs the driver's real `main()` with only the GPU-bound pieces faked.
- On the RTX 5070, both pool GPU tests pass 3 of 3, and fail 3 of 3 with the `wait_stream` removed.

Closes #1174.
MakazhanAlpamys added a commit that referenced this pull request Oct 12, 2026
…e shared slot (#975) (#1781)

`benchmarks/gate-971-async-nvme-source.md` gets two dated correction notes for the embedding entry (#975): one under the opening of section 8 and one under the finding that attributes the embedding stall to the single-member-group behaviour. Both say the same thing. `embed_tokens` and `lm_head` share one device slot, so each step's `_prime()` re-copies the whole matrix host to device with nothing to overlap it (a copy, not a read; fixed by #1178), which supersedes the earlier cause. The stall figures, the record's other findings and the 0.07-0.12% materiality ruling stand. Docs only: no source change, no fragment.

Closes #975
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants