WP-208 — the three CUDA replay holders on shared primitives, segmentation head replay under accumulation, and the red-main rule for CUDA tests¶
Engineering log · Phase 19 — Codebase audit remediation
WP-208 — one copy of the recording, gradient, release and lifecycle steps, and the segmentation head holder moved to the forward-time copy¶
Why. The network, segmentation-head and loss holders (ptl/network_capture.py, ptl/segment_head_capture.py, ptl/loss_capture.py) share one design, but each carried its own copy of the steps they have in common:
- the four global-hook registries, read three times;
- the autocast re-entry with the cache off, written three times;
- the precision signature, which the other two imported from the network holder;
- the BF16 runtime gate, which the loss holder duplicated rather than imported;
- the buffer-snapshot,
fork_rng, synchronize and restore block around recording, in the network and head holders.
They also differed where they should not:
- The head holder still copied live gradients from a backward pre-hook, the pattern WP-206 found wrong on CUDA for the network holder. Because of that it refused gradient accumulation, at setup and again in its runner.
- Only the head holder drained its streams before dropping its graphs; the network and loss holders dropped theirs with replays possibly still queued.
- The loss holder's callback cast
module._loss_capturewithout a check, so a trainer reused on a module without that attribute raisedAttributeError. Itsretirecleared the holder again on every later cause. - On a failed fit, the network and loss callbacks let a failing drain replace the trainer's own exception; the head callback already turned it into a warning.
Change. A new private module, ptl/_graph_replay.py, holds functions rather than a base class, so the holder classes, their attribute names and every warning text stay as they were:
| Primitive | Replaces |
|---|---|
BF16_REPLAY_SUPPORTED |
the version check in the network and loss holders |
precision_signature, tensor_signature |
the network holder's precision read and the storage fingerprint in the network and head holders |
global_hook_breach, module_hook_breach |
the hook checks in all three holders |
replay_autocast |
the autocast re-entry in all three holders |
record_graphed |
the recording block in the network and head holders |
copy_out_live_gradients |
the gradient copy in the network holder and the head holder's pre-hook |
release_graphs, drain_device |
the head holder's per-stream drain, and a device-wide drain for the other two |
ReplayLifecycle |
the three Lightning callbacks, now one-line subclasses under their old names |
Each holder re-binds a moved name under the module-level name it always used (_BF16_REPLAY_SUPPORTED, _BF16_SUPPORTED, _precision_signature, _tensor_signature), so a patch of that module still reaches the value the holder reads. The holders shrink from 706, 561 and 438 lines to 619, 490 and 426, against 354 for the shared module, most of it docstrings and doctests.
The head holder copies at forward time and accumulates. _RecordedGroup.run calls copy_out_live_gradients on the group's parameters right before the replay, and the pre-hook is gone. The post-hook that releases the group's hold stays. That hold is what makes the forward-time copy sufficient, as in WP-206: while a replay's backward is pending, every later call of that group runs native, so nothing else can alias the static gradient buffers before that backward writes them. With the copy moved, the setup condition accumulate_grad_batches == 1 and the runner's "gradient accumulation changed after setup" retirement are removed. The setup warning drops "with accumulate_grad_batches=1". The lifecycle callback no longer writes entry.pending_backward itself; the holder gained start_batch(), the same call the other two make.
Release drains everywhere (D6). release_graphs synchronizes each distinct stream once, in first-seen order, before a holder drops its graphs. The head holder keeps the stream each group recorded on. The network and loss holders do not record a stream. They now record the device, and drain_device synchronizes the whole device, so a replay launched from any stream has finished. It does nothing without a recording, for a non-CUDA device or before CUDA is initialized, so a CPU run and unpickling never touch the CUDA runtime. The drain runs only when a holder is cleared (fit start and end, failure, retirement, loss workspace growth), never per step. Every clear() resets its fields first and keeps the recording alive in a local reference until the drain returns, so a failing drain still leaves an empty holder. The network holder clears every pose stem holder even when an earlier one's drain fails, and then raises the first failure. The reason for draining everywhere rather than proving it unnecessary: nothing shows that dropping a graph pool with replay work still queued on a stream is safe, and the project cannot produce that proof offline. One synchronize at a rare point is the cheaper side of the trade.
The lifecycle fixes (D5). ReplayLifecycle._holder reads the attribute with getattr and checks its type for every holder, so a module without it makes each hook a no-op. on_exception catches a RuntimeError from the drain and warns <flag>=True could not drain its recordings after the fit failed (...), the text the head holder already used. _LossCapture.retire returns early once the holder is disabled: one warning per fit, and no second clear().
Not consolidated, on purpose.
- Dispatch certificates. The network holder and the module certify by method-name shadowing, the head holder by class-method identity plus a type allow-list, and the loss holder by callable identities. Merging them would change what each guard admits, and tests call each check directly.
- Activation gates. The holders read different trainer facts, and their warning texts are pinned by tests; only the accumulation half of the head holder's gate is removed.
- The loss holder's recording. It has no buffers, suspends the garbage collector and makes the first replay inside its own
try, so it keeps its block. Its silent BF16 fallback is unchanged.
A CPU seam for the whole cycle. The network and head holders record only through _graph_replay.record_graphed, looked up on the module at call time. The dry_recording fixture in tests/ptl/conftest.py patches it to return the module itself, so a CPU test runs recording, the forward-time copy, the hold, its release and clear as a CUDA fit does, with the native arithmetic standing in for the graph. Graph numerics are what it cannot show; the gpu tests own them. No production flag was added for it.
The replay flags' lifetime and the red-main rule (D32). The principal answered P5 and the third decision of the 2026-10-20 escalation on 2026-10-23, and D32 records both:
- Red main. A known failing CUDA test leaves
mainred until it is fixed, and a [GPU] work package does not close without a greenmake gate-gpurun.AGENTS.mdcarries it in two places: themake gate-gpusentence of section 2, and "leavemainred" in section 7. WP-209, which landed before this package to make that run green, was the first package closed under it. - Flag lifetime. Each flag is kept or retired on its own record. A new section of CUDA performance gives one row per flag, with its package, documented workload, measured gain and the two triggers. Every documented gain is at least 10%, so no flag is a retirement candidate; nothing is removed here.
Tests.
tests/ptl/test_graph_replay.py(new):- stream drain order and de-duplication;
- no CUDA call without an initialized CUDA recording, and a device-wide synchronize with one;
- drain before release on
clearfor the network and loss holders; - every stem holder cleared, the device drained and the graphed forward dropped whichever of them fails, with the first failure raised;
- a failing drain raised at fit end and warned on a failed fit;
- the no-op callback for a missing or foreign holder, across all three holders.
test_network_capture_guards.py::TestDryRecordingCycle::test_accumulated_microbatches_record_once_and_match_eager: two accumulated micro-batches through the dry seam record once and end bitwise equal to eager.test_segment_head_capture_guards.py:test_live_gradient_leaves_storage_at_forward_time: right afterrun, before any backward, the parameter holds a separate tensor with the old values.test_dry_recording_accumulates_like_native_and_drains_on_clear: two micro-batches through one group record once, hold until backward, match a native run bitwise and drain once onclear.test_fit_setup_activates_under_gradient_accumulationandtest_gradient_accumulation_keeps_certificate: no warning and no retirement at accumulation 2. The accumulation case oftest_contract_drift_retires_with_its_reasonis removed.
test_loss_capture.py::test_later_retire_cause_stays_silent.test_segment_network_capture.py:heads-bf16-accumulatedoftest_segment_fit_replays_across_empty_partial_validation_and_emanow expects both holders to replay with no warning.- New CUDA test
test_segment_head_replay_under_accumulation_matches_native: head replay on and off, BF16, accumulation 3, EMA, clipping and validation. Each window runs a full batch, a partial batch that accumulates natively and a second full batch. The empty-target batch sits alone in the last window. It compares weights, optimizer, EMA and scaler bitwise. A spy on the forward-time copy asserts that every recorded prediction group replayed at least once inside a window, onto the gradients its earlier batches accumulated, so a run whose in-window replay silently went native cannot pass. A batch opening a window is not counted, since the previous step's gradients are alive there at every accumulation setting (see Limits). - The
heads-bf16-accumulatedcell asserts recording, not replay onto accumulated gradients; the dedicated test above carries that claim.
- Two
_RecordedGrouptests built with__new__gaingroup.parameters = (), sincerunnow reads them.
A mutation check, which made copy_out_live_gradients a no-op, failed four tests: the network dry cycle, the head forward-time copy, the head dry cycle and the existing head backward-storage test.
Verification.
- CPU.
make gategreen on this commit's tree, the ninetests/ptl/test_graph_replay.pytests and the new guard and head-accumulation tests among it. - CUDA.
colab_11ranmake gate-gpuon an A100 before WP-209 and was red only on the failures that package fixed, which thecolab_12control reproduced on the tree without this package.make gate-gpugreen on an A100 (py3.11, torch 2.9.1+cu129) at the stack's tip72b5505, the tree WP-218 left: 359 passed and 2 skipped (agrad_dtypecase that needs a newer torch, and a replay test that needs two CUDA devices), every notebook in full,demo_obbon DOTA-v1.0 among them, and 55/55 accelerator goldens.tests/ptl -m "gpu or data"also passed on Colab's py3.13 / torch 2.11 (242 passed). The same selection on this package's own commit,92e775b, passed in that session too (242 passed). That green run closes the package, as D32 requires.
Goldens. No frozen golden is touched. Every replay flag is off by default, and the overfit goldens build their module with the defaults.
Limits.
- Throughput of head replay under accumulation is not measured. This package claims gradient and state equality with native training at accumulations 2 and 3, on the GPU named above.
- A clone on every replayed step, not measured. Lightning's automatic optimization runs the training step's forward, then
zero_grad, then backward (pytorch_lightning 2.6.6,loops/optimization/automatic.py). So the forward-time copy meets the previous step's gradients on every replayed step, at accumulation 1 too: it clones the recorded parameters' gradients, andzero_gradthen drops the clones. The network holder has paid this since WP-206, and the head holder pays it from this package on; its old pre-hook ran afterzero_gradand cloned nothing at accumulation 1. The cost is one device copy of the recorded parameters' gradient bytes per step. No speed-up in the flag-lifetime table was re-measured with this clone in place, so the next measurement of each flag is what confirms it still holds. Avoiding the clone would mean tracking which gradients alias the static buffers. A pose network holder also synchronizes the device once per stem holder onclear, which is rare and left as is. - WP-193c and WP-193d stay
🔄. Their DoD names runs that are still outstanding, and this package does not run them.