Skip to main content

memra_engine/
pp.rs

1//! M2 pipeline-parallel N-stage runtime (generalizes the M1 2-stage seam).
2//!
3//! Door: `MEMRA_PP_STAGES=N` (default OFF — unset/0/1 = no behavior change anywhere).
4//! Stage map: N stages over the trunk layers with N-1 cuts. `MEMRA_PP_SPLITS=c1,..,cN-1`
5//! sets the cuts explicitly (strictly increasing, in (0, n_layers)); `MEMRA_PP_SPLIT=<i>`
6//! is the N=2 back-compat spelling; default = even split (cut s = s*n_layers/N).
7//! Placement: `MEMRA_PP_DEVICES=d0,..,dN-1` maps stage s to device ds (default: all on
8//! the primary engine's device).
9//!
10//! M1 history (increments 1-2, merged + hardened on the 8x box 2026-08-02): seam + gate
11//! single-device; then real transport — per-stage streams/events, device placement,
12//! peer-copy boundary (M0: cudaMemcpyPeerAsync beats NCCL 2.8x at PP activation sizes),
13//! per-context PDL module caches, default-mempool peer grants. All five r3 gates PASS
14//! bit-identical (receipts ~/receipts/m1-pp2/ on darklanes-bench).
15//!
16//! M2 increment 1 (this file): N-STAGE GENERALIZATION — `Pp2Rt` becomes `PpNRt`:
17//!   - `stages`: Vec of per-stage execution homes (device, context, stream, remote Engine);
18//!   - `boundaries`: N-1 boundary runtimes, each with TWO persistent double-buffered slots
19//!     (ev_tx/ev_rx per slot) and its own overlap step counter; transport is selected PER
20//!     BOUNDARY (dtod same-device / cudaMemcpyPeerAsync cross-device);
21//!   - peer + default-mempool access is granted between EVERY distinct pair of devices in
22//!     use (stage devices + the primary): stage kernels may dereference the primary's
23//!     weights (bring-up placement) and stage-0's pos_d, and each boundary peer-copies.
24//!
25//! M2 increment 2 (weight sharding): the loader uploads each stage's layer range THROUGH
26//! that stage's engine (`layer_engine`), so weights land on the device that runs them —
27//! the bring-up peer-read placement dies. `output_norm` + lm head load through the LAST
28//! stage's engine; the embed table stays host-side with stage 0. Split-plane/f16 decode
29//! mirrors are built per layer through the owning stage's engine too (the rp4 mirrors ARE
30//! the decode weights on the q8 path — leaving them on dev0 would fake the kill).
31//! Rollback seam: `MEMRA_PP_SHARD=0` = M1 bring-up placement (all weights on primary,
32//! remote stages peer-read).
33//!
34//! M2 increment 3 (deferred readback — the pipelining seed): `PendingLogits` — the eager
35//! decode arm can END a step without the logits D2H (`decode_step_h_ppn_deferred`): the
36//! logits stay device-resident with a completion event; `wait()` drains them through a
37//! DEDICATED readback stream (waits the event, copies, syncs) so tokens t+1.. keep
38//! enqueuing on the stage streams while token t drains. Per-token math is fully
39//! event-ordered (same slots, same ev_tx/ev_rx chain) — scheduling changes, math does
40//! not; the pipelined replay arm of `ppn-gate` proves bit-identity per step.
41//!
42//! Ownership across a boundary (unchanged from M1):
43//!   - hidden state [n_embd] f32 is the ONLY tensor that crosses;
44//!   - KV/linear-attn cache entries are per-layer: stage s exclusively owns cache state
45//!     for its layer range (and, under MEMRA_PP_DEVICES, allocates it on its device);
46//!   - position/rope state is the scalar `cache.pos` snapshot taken once per step, uploaded
47//!     on stage-0's stream BEFORE the first TX event — every later stage's wait chain
48//!     transitively orders it (stage s waits boundary s-1's ev_tx, which was recorded after
49//!     stage s-1's work, which waited boundary s-2's ev_tx, ... back to stage 0);
50//!   - the embed table lives with stage 0, output_norm + lm head with the last stage.
51//!
52//! THE MULTI-STREAM LAW (why this is safe with cudarc event tracking disabled): all
53//! cross-stage bytes flow through the persistent boundary slots, ordered by ev_tx/ev_rx;
54//! per-stage scratch is allocated AND freed on that stage's stream (stream-ordered); the
55//! async mem pool runs with opportunistic reuse OFF + internal dependencies ON
56//! (memra-runtime), so a block freed on stream A and reused on stream B carries a
57//! driver-inserted dependency. Weights are load-time state no stage stream can precede,
58//! and the step's terminal logits readback (sync D2H, or PendingLogits' event-ordered
59//! readback stream) drains the last stage, whose TX-wait chain transitively drains all.
60//!
61//! Scope: plain eager decode only (generic arm N-stage; gemma4 arm 2-stage). NOT wired:
62//! batch/dc/graph/spec loops and the gemma4-E4B eager arm.
63//!
64//! CORRECTION (pp2-hardening 2026-08-06): this header used to add "(`warn_unwired_once`
65//! fires)" to that list, which was wrong. `warn_unwired_once` has exactly two call sites
66//! and BOTH are gemma4-specific (decode.rs, hybrid_forward.rs) — the batch/dc/graph/spec
67//! loops never warned. Worse, the batched loop did not merely run unsplit: it walked the
68//! whole trunk on the primary stream and, under a sharded cross-device placement,
69//! peer-read every remote stage's weights each step — 28x slower at B=1 with all three
70//! `decode-batch-gate` gates PASSING (peer reads are byte-exact, so only perf broke).
71//! `decode_step_batch` now FAILS CLOSED in that regime via `pp_sharded_cross_device()`
72//! (`MEMRA_PP_ALLOW_UNSPLIT_BATCH=1` = measurement override). "Unwired" for dc/graph/spec
73//! still means "runs unsplit, silently" — audit each before trusting it on a pair.
74
75use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
76use std::sync::{Arc, Mutex, OnceLock};
77
78use cudarc::driver::{CudaContext, CudaEvent, CudaSlice, CudaStream};
79
80use crate::Engine;
81
82/// Returns the stage fence iff the ppN door is open: `MEMRA_PP_STAGES=N` (N >= 2) with a
83/// valid cut list. The fence has N+1 entries: `[0, c1, .., cN-1, n_layers]`; stage s runs
84/// layers `[fence[s], fence[s+1])`. Reads the environment on every call (gates toggle the
85/// door in-process); the cost is a few getenv per decode step, eager-loop noise.
86pub fn pp_cuts(n_layers: usize) -> Option<Vec<usize>> {
87    let n_st: usize = match std::env::var("MEMRA_PP_STAGES") {
88        Ok(v) if v.is_empty() || v == "0" || v == "1" => return None,
89        Ok(v) => match v.parse::<usize>() {
90            Ok(n) => n,
91            Err(_) => {
92                warn_bad_once(&format!("MEMRA_PP_STAGES={v} unparseable; door stays OFF"));
93                return None;
94            }
95        },
96        Err(_) => return None,
97    };
98    if n_st < 2 || n_st > n_layers {
99        warn_bad_once(&format!(
100            "MEMRA_PP_STAGES={n_st} outside [2, n_layers={n_layers}]; door stays OFF"
101        ));
102        return None;
103    }
104    let mut fence = Vec::with_capacity(n_st + 1);
105    fence.push(0usize);
106    if let Ok(s) = std::env::var("MEMRA_PP_SPLITS") {
107        let parts: Result<Vec<usize>, _> =
108            s.split(',').map(|p| p.trim().parse::<usize>()).collect();
109        match parts {
110            Ok(cuts) if cuts.len() == n_st - 1 => fence.extend(cuts),
111            _ => {
112                warn_bad_once(&format!(
113                    "MEMRA_PP_SPLITS={s} invalid (want {} comma-separated cuts); door stays OFF",
114                    n_st - 1
115                ));
116                return None;
117            }
118        }
119    } else if let Ok(v) = std::env::var("MEMRA_PP_SPLIT") {
120        // N=2 back-compat spelling. With N>2 a single split is ambiguous — fail the door
121        // loudly rather than guess (a silent even-split would fake a gate config).
122        if n_st != 2 {
123            warn_bad_once(&format!(
124                "MEMRA_PP_SPLIT={v} set with MEMRA_PP_STAGES={n_st}; use MEMRA_PP_SPLITS \
125                 for N>2 — door stays OFF"
126            ));
127            return None;
128        }
129        match v.parse::<usize>() {
130            Ok(c) => fence.push(c),
131            Err(_) => {
132                warn_bad_once(&format!("MEMRA_PP_SPLIT={v} unparseable; door stays OFF"));
133                return None;
134            }
135        }
136    } else {
137        for s in 1..n_st {
138            fence.push(s * n_layers / n_st);
139        }
140    }
141    fence.push(n_layers);
142    for w in fence.windows(2) {
143        if w[0] >= w[1] {
144            warn_bad_once(&format!(
145                "pp stage fence {fence:?} not strictly increasing over [0, {n_layers}]; \
146                 door stays OFF"
147            ));
148            return None;
149        }
150    }
151    Some(fence)
152}
153
154/// N=2 back-compat view of the door (the gemma4 arm and `pp2-gate` are 2-stage): `Some(cut)`
155/// iff the door is open with EXACTLY two stages.
156pub fn pp2_split(n_layers: usize) -> Option<usize> {
157    pp_cuts(n_layers).filter(|f| f.len() == 3).map(|f| f[1])
158}
159
160/// The stage that owns layer `il` under `fence` (see `pp_cuts`).
161pub fn stage_of(fence: &[usize], il: usize) -> usize {
162    debug_assert!(fence.len() >= 2);
163    match fence[1..fence.len() - 1].binary_search(&il) {
164        // fence[1..][k] == il means il is the FIRST layer of stage k+1
165        Ok(k) => k + 1,
166        Err(k) => k,
167    }
168}
169
170/// MEMRA_PP_STREAMS=0: rollback to the increment-1 same-stream seam (boundary = two plain
171/// dtod copies on the ambient compute stream, no per-stage streams/events/devices).
172pub fn pp2_streams_off() -> bool {
173    matches!(std::env::var("MEMRA_PP_STREAMS").as_deref(), Ok("0"))
174}
175
176/// True iff the ppN door would put TWO OR MORE stage streams on ONE device (devices
177/// unset = all stages on the primary; or an explicit placement with a repeated device).
178/// The deferred-readback (pipelined) arm is REFUSED in this regime: the 2026-08-02 x20
179/// soak record — singledev pipelined 13/20 PASS default, 7 failures each diverging at a
180/// different step (timing-race signature); MEMRA_PDL=0 went 20/20 on one soak but a
181/// second same-config soak on the auto-gated build failed 2/20 (n2) and battery-4 failed
182/// n4 — so PDL narrows the window without closing it, and the true root cause (same
183/// Engine kernels concurrent on two streams of one device) is NOT fixed by any flag yet.
184/// Cross-device pipelined (one stage stream per device) is 23/23 clean post-fix. Refuse
185/// loudly rather than return silently-wrong logits. Env-only read (callable pre-runtime).
186pub fn pp_multi_stream_same_device() -> bool {
187    let stages_open = std::env::var("MEMRA_PP_STAGES")
188        .map(|v| v.parse::<usize>().map(|n| n >= 2).unwrap_or(false))
189        .unwrap_or(false);
190    let devices = std::env::var("MEMRA_PP_DEVICES").ok().filter(|v| !v.is_empty());
191    if (!stages_open && devices.is_none()) || pp2_streams_off() {
192        return false;
193    }
194    match devices {
195        None => true, // door open, no placement: every stage stream lands on the primary
196        Some(s) => {
197            let mut v: Vec<&str> = s.split(',').map(|p| p.trim()).collect();
198            let n = v.len();
199            v.sort_unstable();
200            v.dedup();
201            v.len() < n // repeated device = shared-device streams
202        }
203    }
204}
205
206/// True iff the ppN door is open AND the placement spans 2+ DISTINCT devices AND the
207/// per-stage sharded loader is on — i.e. some layers' weights live on a device other than
208/// the primary. Any path that walks the WHOLE trunk on one stream in this regime reads
209/// those weights over PCIe every step. Env-only read (callable pre-runtime).
210///
211/// Measured cost of doing that (pp2-hardening 2026-08-06, 2x RTX PRO 6000, PCIe Gen5 x16
212/// P2P, decode-batch-bench q9, N=5 interleaved, `research/pp2-hardening-20260806`):
213/// **B=1 7.4 vs 208.9 tok/s (28x), B=4 29.8 vs 491.3 (16.5x), B=8 47.4 vs 657.0 (13.9x)**.
214/// The same sweep with `MEMRA_PP_SHARD=0` (weights all home) returns 178.5/491.1/656.6 —
215/// identical to the single-device door-open arm — so the entire cliff is the peer read,
216/// not the door and not the placement plumbing. Exactness is NOT the issue: peer reads
217/// return identical bytes and every `decode-batch-gate` gate PASSED on this config, which
218/// is precisely why it needs a refusal rather than a gate.
219pub fn pp_sharded_cross_device() -> bool {
220    let stages_open = std::env::var("MEMRA_PP_STAGES")
221        .map(|v| v.parse::<usize>().map(|n| n >= 2).unwrap_or(false))
222        .unwrap_or(false);
223    // MEMRA_PP_STREAMS=0 (2026-08-06, pp2-batch): the same-stream rollback seam ALSO turns
224    // the sharded loader off — `layer_engine` returns the primary engine whenever
225    // `pp2_streams_off()`, and `new_cache` skips `Cache::new_ppn` on the same condition. So
226    // in that regime every weight and every cache is home on the primary and an unsplit walk
227    // peer-reads NOTHING. Without this term the guard refused that config too: a spurious
228    // refusal of a placement that is sound and full-speed. Found wiring the batched pp arm.
229    if !stages_open || pp_shard_off() || pp2_streams_off() {
230        return false;
231    }
232    match pp2_devices_env() {
233        None => false, // no placement: every stage is the primary device, nothing remote
234        Some(s) => {
235            let mut v: Vec<&str> = s.split(',').map(|p| p.trim()).collect();
236            v.sort_unstable();
237            v.dedup();
238            v.len() >= 2
239        }
240    }
241}
242
243/// The shared fail-closed guard for EVERY decode path that has no pp stage split.
244/// Returns `Err` iff `pp_sharded_cross_device()` — i.e. the caller would walk the whole
245/// trunk on one stream while some layers' weights live on another device, peer-reading
246/// them every step. `path` names the refusing function so the operator knows which loop
247/// they hit; `alt` names the working alternative for that loop.
248///
249/// One helper rather than four copies because the audit found FOUR paths with the same
250/// hole (`decode_step_batch`, `decode_step_dc`, the graph capture that wraps dc, and
251/// `decode_step_t*` verify), and a per-path copy is how one gets missed on the next
252/// addition. Override: `MEMRA_PP_ALLOW_UNSPLIT_BATCH=1` (one door for all of them —
253/// they are the same measurement question).
254pub fn refuse_unsplit_if_remote(path: &str, alt: &str) -> Result<(), Box<dyn std::error::Error>> {
255    if pp_sharded_cross_device()
256        && std::env::var("MEMRA_PP_ALLOW_UNSPLIT_BATCH").as_deref() != Ok("1")
257    {
258        return Err(format!(
259            "{path}: refused with the ppN door open across 2+ devices — this path has no pp \
260             stage split, so it would walk ALL layers on one stream and peer-read every \
261             remote stage's weights each step (measured 28x slower at B=1, 13.9x at B=8 on \
262             a PRO 6000 pair over PCIe Gen5 x16 P2P; research/pp2-hardening-20260806). \
263             Exactness is unaffected — peer reads return identical bytes and the exactness \
264             gates PASS on this config — which is exactly why it must refuse instead of \
265             being caught by a gate. Fixes, in order: {alt}; or MEMRA_PP_SHARD=0 (all \
266             weights home on the primary — full speed, forfeits the capacity PP-2 exists \
267             for); or close the pp door. MEMRA_PP_ALLOW_UNSPLIT_BATCH=1 overrides for \
268             measurement."
269        )
270        .into());
271    }
272    Ok(())
273}
274
275/// MEMRA_BATCH_PP=0: rollback/A-B seam for the BATCHED stage split (pp2-batch 2026-08-06).
276/// Default ON — with the ppN door open the batched decode step takes its own stage split
277/// (`decode_step_batch_ppn`) exactly as the eager step does. Setting 0 sends the batched
278/// path back through the unsplit body, which under a sharded cross-device placement is
279/// then caught by `refuse_unsplit_if_remote` (the 28x peer-read regime) rather than run
280/// silently. Exists so the bit-identity gate can A/B split vs unsplit IN ONE PROCESS
281/// against the same loaded weights — read per step, never memoized, for that reason.
282pub fn batch_pp_on() -> bool {
283    std::env::var("MEMRA_BATCH_PP").as_deref() != Ok("0")
284}
285
286/// MEMRA_PRIME_PP=0: rollback/A-B seam for the PRIME (chunked prefill) stage split
287/// (lane/pp-leverb 2026-08-08). Default ON — with the ppN door open the chunked prime takes
288/// its own per-stage range walk exactly as the eager/batched/verify steps do. Setting 0 sends
289/// prime back through the unsplit whole-trunk walk. NOTE: unlike batch/dc/graph/spec, prime
290/// keeps NO `refuse_unsplit_if_remote` — its unsplit walk over a sharded placement is the
291/// measured 22% amortized peer-read tax (research/pp-prefill-20260807 anatomy: m=4096
292/// amortizes the weight reads), not the decode 28x cliff, and the unsplit walk IS the
293/// split-vs-unsplit gate's reference arm (`prime-split-gate`), so it must stay callable.
294/// Read per call, never memoized (the gate A/Bs both arms in one process).
295pub fn prime_pp_on() -> bool {
296    std::env::var("MEMRA_PRIME_PP").as_deref() != Ok("0")
297}
298
299/// MEMRA_PRIME_PIPE=0: rollback/A-B seam for the PP-2 PRIME CHUNK PIPELINE
300/// (lane/cx-pipeline-prime 2026-08-08). Default ON when the prime stage split is live;
301/// setting 0 keeps the serial per-chunk stage walk. Read per prime call so the exactness
302/// gate can replay both schedules against one loaded model.
303pub fn prime_pipe_on() -> bool {
304    std::env::var("MEMRA_PRIME_PIPE").as_deref() != Ok("0")
305}
306
307/// SPLIT-LIVENESS COUNTER for the prime stage split: bumped ONCE per prime chunk that
308/// actually executed the per-stage walk. The `prime-split-gate` requires this to ADVANCE
309/// during its split arm — bit-identity of two identical UNSPLIT walks is vacuous, so a gate
310/// that only compared bits would go green while the walker doesn't exist. With the counter,
311/// the gate is RED until the walker lands (the tickinv35 pattern: the gate exists and fails
312/// before the mechanism does). Relaxed ordering: single-threaded host issue, count-only.
313pub static PRIME_SPLIT_CHUNKS: AtomicUsize = AtomicUsize::new(0);
314
315/// Read the split-liveness counter (gate-side).
316pub fn prime_split_chunks() -> usize {
317    PRIME_SPLIT_CHUNKS.load(Ordering::Relaxed)
318}
319
320/// PIPELINE-LIVENESS COUNTER: bumped only when a second PP-2 prime stage enters its layer
321/// walker while the other stage's walker is still active. Step's per-layer router readback
322/// synchronizes the host, so enqueue order alone is not liveness: a single host thread can
323/// call stage 0(N+1) before the stage-1 epilogue and still serialize all trunk computation.
324pub static PRIME_PIPE_OVERLAPS: AtomicUsize = AtomicUsize::new(0);
325
326/// Read the prime-pipeline overlap counter (gate-side).
327pub fn prime_pipe_overlaps() -> usize {
328    PRIME_PIPE_OVERLAPS.load(Ordering::Relaxed)
329}
330
331static PRIME_PIPE_ACTIVE_STAGES: AtomicUsize = AtomicUsize::new(0);
332
333pub(crate) struct PrimePipeStageGuard;
334
335/// Mark one host-driven stage walker active. With PP-2, a transition 1 -> 2 proves the
336/// two device walkers overlap in wall time; exactly one transition is counted per pair.
337pub(crate) fn enter_prime_pipe_stage() -> PrimePipeStageGuard {
338    let active = PRIME_PIPE_ACTIVE_STAGES.fetch_add(1, Ordering::AcqRel);
339    if active > 0 {
340        PRIME_PIPE_OVERLAPS.fetch_add(1, Ordering::Relaxed);
341    }
342    PrimePipeStageGuard
343}
344
345impl Drop for PrimePipeStageGuard {
346    fn drop(&mut self) {
347        let active = PRIME_PIPE_ACTIVE_STAGES.fetch_sub(1, Ordering::AcqRel);
348        debug_assert!(active > 0, "prime pipeline active-stage counter underflow");
349    }
350}
351
352/// Step35 cross-request prime liveness counters (lane/cx-prime-batch, 2026-08-08).
353/// The exactness gate requires BOTH to advance: a successful step35 batch alone is not
354/// sufficient under PP-N if it walked the whole sharded trunk on one stream.
355pub static STEP35_PRIME_BATCHES: AtomicUsize = AtomicUsize::new(0);
356pub static STEP35_PRIME_BATCH_SPLITS: AtomicUsize = AtomicUsize::new(0);
357
358pub fn step35_prime_batches() -> usize {
359    STEP35_PRIME_BATCHES.load(Ordering::Relaxed)
360}
361
362pub fn step35_prime_batch_splits() -> usize {
363    STEP35_PRIME_BATCH_SPLITS.load(Ordering::Relaxed)
364}
365
366/// MEMRA_SPEC_PP=0: rollback/A-B seam for the SPEC VERIFY stage split (pp2-spec 2026-08-06).
367/// Default ON — with the ppN door open the verify forward (`decode_step_t_core_ppn`) takes its
368/// own stage split exactly as the eager and batched steps do. Setting 0 sends verify back through
369/// the unsplit trunk walk, which under a sharded cross-device placement is then caught by
370/// `refuse_unsplit_if_remote` (the 28x peer-read regime) rather than running silently. Exists so
371/// the bit-identity gate can A/B split vs unsplit IN ONE PROCESS against the same loaded weights
372/// — read per verify call, never memoized, for that reason.
373pub fn spec_pp_on() -> bool {
374    std::env::var("MEMRA_SPEC_PP").as_deref() != Ok("0")
375}
376
377/// MEMRA_PP_OVERLAP=1: alternate the double-buffered boundary slots per step (the
378/// pipelining seed). Default OFF — scheduling structure only, never math. Read per step
379/// so gates can A/B in-process.
380pub fn pp2_overlap() -> bool {
381    matches!(std::env::var("MEMRA_PP_OVERLAP").as_deref(), Ok("1"))
382}
383
384/// M2 increment 2 rollback seam: MEMRA_PP_SHARD=0 = the M1 bring-up placement (all
385/// weights upload through the primary engine; remote stages peer-read). Default ON —
386/// under MEMRA_PP_DEVICES each stage's layer range uploads through its own engine.
387pub fn pp_shard_off() -> bool {
388    matches!(std::env::var("MEMRA_PP_SHARD").as_deref(), Ok("0"))
389}
390
391/// Raw `MEMRA_PP_DEVICES` (parsed/validated at PpNRt build — a bad string must fail the
392/// decode step loudly, never silently fall back to same-device and fake a gate PASS).
393fn pp2_devices_env() -> Option<String> {
394    std::env::var("MEMRA_PP_DEVICES").ok().filter(|v| !v.is_empty())
395}
396
397static WARNED_BAD: AtomicBool = AtomicBool::new(false);
398fn warn_bad_once(msg: &str) {
399    if !WARNED_BAD.swap(true, Ordering::Relaxed) {
400        eprintln!("[pp] {msg}");
401    }
402}
403
404static WARNED_UNWIRED: AtomicBool = AtomicBool::new(false);
405/// One-time notice when the door is set but the executing path has no pp arm
406/// (M2 wires the generic eager decode at any N and the gemma4 eager arm at N=2).
407pub fn warn_unwired_once(path: &str) {
408    let open = std::env::var("MEMRA_PP_STAGES")
409        .map(|v| !v.is_empty() && v != "0" && v != "1")
410        .unwrap_or(false);
411    if open && !WARNED_UNWIRED.swap(true, Ordering::Relaxed) {
412        eprintln!(
413            "[pp] MEMRA_PP_STAGES set but `{path}` has no pp arm at this N; running unsplit"
414        );
415    }
416}
417
418// ======================================================================================
419//  PpNRt: the M2 transport runtime (per-stage streams, per-boundary events + slots)
420// ======================================================================================
421
422/// One pipeline stage's execution home: device, context, launch stream, and (for a stage
423/// remote to the primary engine's device) a dedicated Engine in that device's primary
424/// context (CUmodules are per-context).
425pub struct StageRt {
426    pub dev: usize,
427    pub ctx: Arc<CudaContext>,
428    pub stream: Arc<CudaStream>,
429    /// `Some` only when `dev` differs from the primary engine's device.
430    engine: Option<Engine>,
431}
432
433/// One boundary slot: a persistent RX-side buffer + its TX/RX completion events.
434/// PERSISTENT because the buffer is written by the TX stage's stream and read by the RX
435/// stage's: a per-step alloc/free would enqueue the free on ONE stream while the other
436/// might still be reading (the cross-stream free hazard) — a never-freed slot cannot race.
437struct BoundarySlot {
438    buf: Mutex<Option<CudaSlice<f32>>>,
439    /// Recorded on the TX stage's stream after the TX copy; RX waits on it. Created in
440    /// the TX stage's context (cuEventRecord requires event ctx == stream ctx).
441    ev_tx: CudaEvent,
442    /// Recorded on the RX stage's stream after the RX copy; the NEXT TX into this slot
443    /// waits on it (write-after-read guard). Created in the RX stage's context. Waiting
444    /// on a never-recorded event is a defined no-op, so step 0 needs no special case.
445    ev_rx: CudaEvent,
446}
447
448/// Boundary b sits between stage b (TX) and stage b+1 (RX). Two slots, alternating per
449/// step under MEMRA_PP_OVERLAP=1 (each boundary counts its own steps — a decode step
450/// crosses every boundary exactly once, so the counters stay in lockstep).
451struct BoundaryRt {
452    slots: [BoundarySlot; 2],
453    step: AtomicUsize,
454    /// true iff stage b and stage b+1 live on different devices (peer transport).
455    cross: bool,
456}
457
458pub struct PpNRt {
459    stages: Vec<StageRt>,
460    boundaries: Vec<BoundaryRt>,
461    /// true iff ANY boundary crosses devices.
462    cross_any: bool,
463    /// Dedicated readback stream in the LAST stage's context (deferred logits D2H —
464    /// waiting there instead of on the compute stream keeps later tokens enqueuable).
465    readback: Arc<CudaStream>,
466}
467
468/// M1 name kept alive for external callers (`pp-transport-smoke`, receipts, docs).
469pub type Pp2Rt = PpNRt;
470
471static RTN: OnceLock<Result<PpNRt, String>> = OnceLock::new();
472
473impl PpNRt {
474    /// The process-wide transport runtime, built on first use against the primary engine.
475    /// The stage count + device map freeze at first build (one config per process — gates
476    /// run one placement per invocation). Build errors are sticky and loud.
477    pub fn get(e: &Engine) -> Result<&'static PpNRt, Box<dyn std::error::Error>> {
478        RTN.get_or_init(|| Self::build(e).map_err(|err| err.to_string()))
479            .as_ref()
480            .map_err(|s| -> Box<dyn std::error::Error> { s.clone().into() })
481    }
482
483    fn build(e: &Engine) -> Result<PpNRt, Box<dyn std::error::Error>> {
484        let primary_dev = e.ctx().ordinal();
485        // Stage count: MEMRA_PP_DEVICES length wins when set (it IS the placement);
486        // else MEMRA_PP_STAGES; else 2 (the M1 default — pp-transport-smoke runs doorless).
487        let devices: Vec<usize> = match pp2_devices_env() {
488            Some(s) => {
489                let parts: Result<Vec<usize>, _> =
490                    s.split(',').map(|p| p.trim().parse::<usize>()).collect();
491                match parts {
492                    Ok(v) if v.len() >= 2 => v,
493                    _ => {
494                        return Err(format!(
495                            "MEMRA_PP_DEVICES={s} unparseable (want <d0>,..,<dN-1> e.g. 0,1,2,3)"
496                        )
497                        .into())
498                    }
499                }
500            }
501            None => {
502                let n_st = std::env::var("MEMRA_PP_STAGES")
503                    .ok()
504                    .and_then(|v| v.parse::<usize>().ok())
505                    .filter(|&n| n >= 2)
506                    .unwrap_or(2);
507                vec![primary_dev; n_st]
508            }
509        };
510        if let Ok(v) = std::env::var("MEMRA_PP_STAGES") {
511            if let Ok(n) = v.parse::<usize>() {
512                if n >= 2 && n != devices.len() {
513                    return Err(format!(
514                        "MEMRA_PP_DEVICES lists {} devices but MEMRA_PP_STAGES={n} — \
515                         refusing an ambiguous placement",
516                        devices.len()
517                    )
518                    .into());
519                }
520            }
521        }
522        let n_st = devices.len();
523        let cross_any = devices.iter().any(|&d| d != devices[0]);
524
525        // Every distinct device pair in use must peer-access BOTH ways: boundaries copy
526        // between consecutive stages, stage kernels may dereference primary-device weights
527        // (bring-up placement / MEMRA_PP_SHARD=0) and stage-0's pos_d upload.
528        let mut used: Vec<usize> = devices.clone();
529        used.push(primary_dev);
530        used.sort_unstable();
531        used.dedup();
532        if used.len() > 1 {
533            let n = cudarc::driver::result::device::get_count()? as usize;
534            for &d in &used {
535                if d >= n {
536                    return Err(format!(
537                        "MEMRA_PP_DEVICES={devices:?} but only {n} CUDA device(s) present"
538                    )
539                    .into());
540                }
541            }
542            for &a in &used {
543                for &b in &used {
544                    if a == b {
545                        continue;
546                    }
547                    let da = cudarc::driver::result::device::get(a as i32)?;
548                    let db = cudarc::driver::result::device::get(b as i32)?;
549                    let mut can: i32 = 0;
550                    unsafe {
551                        cudarc::driver::sys::cuDeviceCanAccessPeer(&mut can, da, db).result()?;
552                    }
553                    if can == 0 {
554                        return Err(format!(
555                            "device {a} cannot peer-access device {b} (cuDeviceCanAccessPeer=0); \
556                             ppN cross-device needs P2P — refusing a silently-staged path"
557                        )
558                        .into());
559                    }
560                }
561            }
562        }
563
564        // PER-STAGE ENGINE ISOLATION (2026-08-02 singledev pipelined find): Engine owns
565        // lazily-grown SHARED scratch pools (fa_part_pool, fa_vf16_scratch, argmax
566        // partials, ...) that are stable-pointer by design — safe on one stream, a data
567        // race the moment two stage streams run concurrently through the SAME Engine
568        // (deferred readback, >=2 tokens in flight: token t+1's stage-0 fa memsets the
569        // partials while token t's stage-s fa still reads them — the nondeterministic
570        // all-logits divergence; cross-device arms were immune because remote stages
571        // already got their own Engine). Every stage s>0 gets its OWN Engine even on the
572        // primary device: same CUcontext (primary retain), so the per-context CUmodule
573        // cache makes it cheap; scratch pools are per-Engine, so stages never share.
574        // Stage 0 keeps the primary engine (single-threaded host issue: the only
575        // concurrent user of `e` during a pp walk is stage 0 itself).
576        let mk_stage = |dev: usize, s: usize| -> Result<StageRt, Box<dyn std::error::Error>> {
577            if dev == primary_dev && s == 0 {
578                let ctx = e.ctx().clone();
579                let stream = ctx.new_stream()?;
580                Ok(StageRt { dev, ctx, stream, engine: None })
581            } else {
582                let eng = Engine::new(dev)?;
583                let ctx = eng.ctx().clone();
584                let stream = ctx.new_stream()?;
585                Ok(StageRt { dev, ctx, stream, engine: Some(eng) })
586            }
587        };
588        let mut stages = Vec::with_capacity(n_st);
589        for (s, &d) in devices.iter().enumerate() {
590            stages.push(mk_stage(d, s)?);
591        }
592
593        if used.len() > 1 {
594            // A context per distinct device (first stage that lives there; the primary's
595            // context for the primary device).
596            let ctx_of = |d: usize| -> &Arc<CudaContext> {
597                if d == primary_dev {
598                    e.ctx()
599                } else {
600                    &stages.iter().find(|s| s.dev == d).unwrap().ctx
601                }
602            };
603            // Enable peer access BOTH ways for every distinct pair (idempotent;
604            // ALREADY_ENABLED is success).
605            for &a in &used {
606                for &b in &used {
607                    if a == b {
608                        continue;
609                    }
610                    ctx_of(a).bind_to_thread()?;
611                    let rc = unsafe {
612                        cudarc::driver::sys::cuCtxEnablePeerAccess(ctx_of(b).cu_ctx(), 0)
613                    };
614                    use cudarc::driver::sys::cudaError_enum as E;
615                    if rc != E::CUDA_SUCCESS && rc != E::CUDA_ERROR_PEER_ACCESS_ALREADY_ENABLED {
616                        return Err(format!(
617                            "cuCtxEnablePeerAccess(dev{a} -> dev{b}) failed: {rc:?}"
618                        )
619                        .into());
620                    }
621                }
622            }
623            // MEM-POOL access grant (8x box 2026-08-02, M1 cross-device fix #2):
624            // cuCtxEnablePeerAccess does NOT map STREAM-ORDERED POOL allocations, and every
625            // engine buffer/weight goes through the device default pool (cuMemAllocAsync via
626            // cudarc; memra-runtime configures that pool). A stage kernel dereferencing
627            // another device's weights — or a boundary peer TX writing the RX slot — needs
628            // cuMemPoolSetAccess on the OWNING device's default pool for the ACCESSING
629            // device; without it the first remote dereference is CUDA_ERROR_ILLEGAL_ADDRESS
630            // (reported at the next API call in the poisoned context). Grant all pairs.
631            for &owner in &used {
632                for &accessor in &used {
633                    if owner == accessor {
634                        continue;
635                    }
636                    let dev = cudarc::driver::result::device::get(owner as i32)?;
637                    let mut pool: cudarc::driver::sys::CUmemoryPool = std::ptr::null_mut();
638                    unsafe {
639                        cudarc::driver::sys::cuDeviceGetDefaultMemPool(&mut pool, dev).result()?;
640                    }
641                    let desc = cudarc::driver::sys::CUmemAccessDesc {
642                        location: cudarc::driver::sys::CUmemLocation {
643                            type_: cudarc::driver::sys::CUmemLocationType::CU_MEM_LOCATION_TYPE_DEVICE,
644                            id: accessor as i32,
645                        },
646                        flags: cudarc::driver::sys::CUmemAccess_flags::CU_MEM_ACCESS_FLAGS_PROT_READWRITE,
647                    };
648                    let rc = unsafe { cudarc::driver::sys::cuMemPoolSetAccess(pool, &desc, 1) };
649                    if rc != cudarc::driver::sys::cudaError_enum::CUDA_SUCCESS {
650                        return Err(format!(
651                            "cuMemPoolSetAccess(dev{owner} pool -> dev{accessor}) failed: {rc:?}"
652                        )
653                        .into());
654                    }
655                }
656            }
657            // MEM-POOL access grant (8x box 2026-08-02, cross-device fix #2):
658            // cuCtxEnablePeerAccess does NOT map STREAM-ORDERED POOL allocations, and every
659            // engine buffer/weight goes through the device default pool (cuMemAllocAsync via
660            // cudarc; memra-runtime configures that pool). A stage-1 kernel dereferencing
661            // dev0 weights — or the stage-0 peer TX writing dev1's RX slot — needs
662            // cuMemPoolSetAccess on the OWNING device's default pool for the ACCESSING
663            // device; without it the first remote dereference is CUDA_ERROR_ILLEGAL_ADDRESS
664            // (reported at the next API call in the poisoned context). Grant both ways.
665            for (owner, accessor) in [(stages[0].dev, stages[1].dev), (stages[1].dev, stages[0].dev)] {
666                let dev = cudarc::driver::result::device::get(owner as i32)?;
667                let mut pool: cudarc::driver::sys::CUmemoryPool = std::ptr::null_mut();
668                unsafe {
669                    cudarc::driver::sys::cuDeviceGetDefaultMemPool(&mut pool, dev).result()?;
670                }
671                let desc = cudarc::driver::sys::CUmemAccessDesc {
672                    location: cudarc::driver::sys::CUmemLocation {
673                        type_: cudarc::driver::sys::CUmemLocationType::CU_MEM_LOCATION_TYPE_DEVICE,
674                        id: accessor as i32,
675                    },
676                    flags: cudarc::driver::sys::CUmemAccess_flags::CU_MEM_ACCESS_FLAGS_PROT_READWRITE,
677                };
678                let rc = unsafe { cudarc::driver::sys::cuMemPoolSetAccess(pool, &desc, 1) };
679                if rc != cudarc::driver::sys::cudaError_enum::CUDA_SUCCESS {
680                    return Err(format!(
681                        "cuMemPoolSetAccess(dev{owner} pool -> dev{accessor}) failed: {rc:?}"
682                    )
683                    .into());
684                }
685            }
686            // restore the primary context for the caller's subsequent work
687            e.ctx().bind_to_thread()?;
688            eprintln!(
689                "[pp] cross-device transport: {} (cudaMemcpyPeerAsync per cross boundary; \
690                 peer + default-pool access granted all pairs over {used:?}; weight home: {})",
691                devices
692                    .iter()
693                    .enumerate()
694                    .map(|(s, d)| format!("stage{s}=dev{d}"))
695                    .collect::<Vec<_>>()
696                    .join(" "),
697                if pp_shard_off() {
698                    format!("dev{primary_dev} (MEMRA_PP_SHARD=0 bring-up placement)")
699                } else {
700                    "per-stage (sharded loader)".to_string()
701                }
702            );
703        }
704
705        let mk_slot = |tx: &StageRt, rx: &StageRt| -> Result<BoundarySlot, Box<dyn std::error::Error>> {
706            Ok(BoundarySlot {
707                buf: Mutex::new(None),
708                ev_tx: tx.ctx.new_event(None)?,
709                ev_rx: rx.ctx.new_event(None)?,
710            })
711        };
712        let mut boundaries = Vec::with_capacity(n_st - 1);
713        for b in 0..n_st - 1 {
714            let (tx, rx) = (&stages[b], &stages[b + 1]);
715            boundaries.push(BoundaryRt {
716                slots: [mk_slot(tx, rx)?, mk_slot(tx, rx)?],
717                step: AtomicUsize::new(0),
718                cross: tx.dev != rx.dev,
719            });
720        }
721        let readback = stages[n_st - 1].ctx.new_stream()?;
722        Ok(PpNRt { stages, boundaries, cross_any, readback })
723    }
724
725    pub fn n_stages(&self) -> usize {
726        self.stages.len()
727    }
728
729    /// True iff any boundary crosses devices (transport = cudaMemcpyPeerAsync there).
730    pub fn cross_device(&self) -> bool {
731        self.cross_any
732    }
733
734    /// The engine a stage's subgraph must run through: the primary engine when the stage
735    /// lives on the primary device, else the stage's own (remote-context) engine.
736    pub fn engine<'a>(&'a self, s: usize, primary: &'a Engine) -> &'a Engine {
737        self.stages[s].engine.as_ref().unwrap_or(primary)
738    }
739
740    /// Bind this OS thread to stage `s`'s CUDA context before issuing work there.
741    pub fn bind_stage(&self, s: usize) -> Result<(), Box<dyn std::error::Error>> {
742        self.stages[s].ctx.bind_to_thread()?;
743        Ok(())
744    }
745
746    /// Enter stage `s`: until the guard drops, every engine op on this thread launches on
747    /// the stage's stream (memra_runtime ambient-stream override).
748    pub fn enter(&self, s: usize) -> memra_runtime::StreamOverride {
749        memra_runtime::push_stream_override(self.stages[s].stream.clone())
750    }
751
752    /// Allocate/grow BOTH slots for a boundary before pipelined issue starts. `tx()` can
753    /// grow a slot lazily, but first-use ordering requires synchronizing the RX stream
754    /// after that allocation. If slot 1 first grows after stage 1 of chunk N has already
755    /// been queued, that sync drains chunk N and erases the only overlap in a two-chunk
756    /// prime. Prewarming both slots pays the same one-time sync before either stage starts.
757    pub fn prepare_overlap_slots(&self, b: usize, n: usize)
758                                 -> Result<(), Box<dyn std::error::Error>> {
759        let bd = &self.boundaries[b];
760        let s_rx = &self.stages[b + 1].stream;
761        let mut grew = false;
762        for sl in &bd.slots {
763            let mut guard = sl.buf.lock().unwrap();
764            if guard.as_ref().map(|bf| bf.len() < n).unwrap_or(true) {
765                *guard = Some(s_rx.alloc_zeros::<f32>(n)?);
766                grew = true;
767            }
768        }
769        if grew {
770            s_rx.synchronize()?;
771        }
772        Ok(())
773    }
774
775    /// Boundary TX at boundary `b` (call within the stage-`b` scope; `x` = the
776    /// materialized [n] residual): wait for the slot's previous RX (write-after-read
777    /// guard), copy `x` into the slot's persistent buffer via the boundary's transport on
778    /// stage-b's stream (the owning-stream/publication law), record ev_tx. Returns the
779    /// slot index for the paired rx().
780    ///
781    /// `n` is the PAYLOAD ELEMENT COUNT, not a fixed model constant: the eager arm passes
782    /// `n_embd` (one row), the batched arm passes `b_n * n_embd` (B stacked rows, the
783    /// [B, n_embd] boundary). The slot buffer is GROW-ONLY and the transport moves exactly
784    /// the first `n` elements — batched serving changes B every tick (chunk fill), and a
785    /// realloc-on-every-size-change would host-sync the RX stream per width change (see the
786    /// SLOT FIRST-USE ORDERING note below for why each allocation needs that sync). Growing
787    /// to the high-water mark makes the syncs O(distinct widths) instead of O(width changes).
788    pub fn tx(&self, b: usize, x: &CudaSlice<f32>, n: usize)
789              -> Result<usize, Box<dyn std::error::Error>> {
790        assert_eq!(x.len(), n, "pp tx: residual length mismatch");
791        let bd = &self.boundaries[b];
792        let slot_idx = if pp2_overlap() {
793            bd.step.fetch_add(1, Ordering::Relaxed) % 2
794        } else {
795            0
796        };
797        self.tx_slot(b, x, n, slot_idx)
798    }
799
800    /// Pipelined boundary TX: always alternate the shared double-buffer slots, independent
801    /// of the decode-side `MEMRA_PP_OVERLAP` experiment flag. The boundary-local atomic
802    /// keeps concurrent callers on one slot sequence rather than each restarting at A.
803    pub fn tx_pipelined(&self, b: usize, x: &CudaSlice<f32>, n: usize)
804                        -> Result<usize, Box<dyn std::error::Error>> {
805        assert_eq!(x.len(), n, "pp tx: residual length mismatch");
806        let slot_idx = self.boundaries[b].step.fetch_add(1, Ordering::Relaxed) % 2;
807        self.tx_slot(b, x, n, slot_idx)
808    }
809
810    fn tx_slot(&self, b: usize, x: &CudaSlice<f32>, n: usize, slot_idx: usize)
811               -> Result<usize, Box<dyn std::error::Error>> {
812        debug_assert!(slot_idx < 2);
813        let bd = &self.boundaries[b];
814        let sl = &bd.slots[slot_idx];
815        let s_tx = &self.stages[b].stream;
816        s_tx.wait(&sl.ev_rx)?;
817        let mut guard = sl.buf.lock().unwrap();
818        if guard.as_ref().map(|bf| bf.len() < n).unwrap_or(true) {
819            // allocated on the RX stage's stream: the buffer lives on the RX device.
820            let s_rx = &self.stages[b + 1].stream;
821            *guard = Some(s_rx.alloc_zeros::<f32>(n)?);
822            // SLOT FIRST-USE ORDERING (2026-08-02 pipelined-gate find): the lazy alloc's
823            // pool-alloc + memset enqueue on the RX stream; the TX copy below issues on
824            // the TX stream, and on a slot's FIRST use ev_rx has never been recorded —
825            // nothing orders them. With >=2 tokens in flight the RX stream is still busy
826            // with the previous token, the memset lands AFTER the TX copy, and the
827            // boundary residual is zeroed (window=1 passed, window>=2 failed at the
828            // slot-1 first-use step; -overlap arms passed because the synchronous serial
829            // arm pre-warmed both slots). Host-sync the RX stream once per slot
830            // allocation — at most 2*(N-1) one-time syncs per process, all during prime.
831            s_rx.synchronize()?;
832        }
833        let buf = guard.as_mut().unwrap();
834        if !bd.cross {
835            s_tx.memcpy_dtod(x, buf)?;
836        } else {
837            // cudaMemcpyPeerAsync (M0: 2.8x NCCL at PP activation sizes), issued on the
838            // publishing TX stream with explicit src/dst contexts.
839            use cudarc::driver::{DevicePtr, DevicePtrMut};
840            let (sp, _g0) = x.device_ptr(s_tx);
841            let (dp, _g1) = buf.device_ptr_mut(s_tx);
842            self.stages[b].ctx.bind_to_thread()?;
843            unsafe {
844                cudarc::driver::result::memcpy_peer_async(
845                    self.stages[b + 1].ctx.cu_ctx(),
846                    dp,
847                    self.stages[b].ctx.cu_ctx(),
848                    sp,
849                    n * std::mem::size_of::<f32>(),
850                    s_tx.cu_stream(),
851                )?;
852            }
853        }
854        sl.ev_tx.record(s_tx)?;
855        Ok(slot_idx)
856    }
857
858    /// Boundary RX at boundary `b` (call within the stage-`b+1` scope): wait on the slot's
859    /// ev_tx, copy the boundary buffer into a fresh working buffer (dtod on the RX stream —
860    /// local on the RX device in both transports), record ev_rx. The returned buffer is
861    /// RX-stage-owned: allocated, consumed, and eventually freed on that stage's stream.
862    pub fn rx(&self, b: usize, slot_idx: usize, n: usize)
863              -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
864        let sl = &self.boundaries[b].slots[slot_idx];
865        let s_rx = &self.stages[b + 1].stream;
866        s_rx.wait(&sl.ev_tx)?;
867        let guard = sl.buf.lock().unwrap();
868        let buf = guard.as_ref().expect("pp rx before tx");
869        assert!(buf.len() >= n, "pp rx: slot holds {} < requested {n}", buf.len());
870        // uninit working buffer (fully overwritten by the copy), allocated explicitly on
871        // the stage stream so rx() is correct even outside an enter() scope.
872        let mut work = unsafe { s_rx.alloc::<f32>(n)? };
873        // Slice the slot to the payload: the buffer is grow-only (see tx), so at a narrower
874        // width it is LONGER than `work` and cudarc's memcpy_dtod (dst.len() >= src.len())
875        // would assert. The paired tx wrote exactly these first n elements.
876        s_rx.memcpy_dtod(&buf.slice(0..n), &mut work)?;
877        sl.ev_rx.record(s_rx)?;
878        Ok(work)
879    }
880
881    /// PUBLISH a DEVICE-RESIDENT result off the last stage to the caller's stream
882    /// (lane/pp2-spec 2026-08-06).
883    ///
884    /// Every ppN body before this one returned HOST values — `decode_step_h_ppn` and
885    /// `decode_step_batch_ppn` both `dtoh` inside the last-stage scope, and a dtoh on the
886    /// producing stream is self-ordering. The verify trunk is the FIRST ppN body whose
887    /// contract is device-resident output (`decode_step_t_h_emb_dev` exists precisely so the
888    /// accept walk argmaxes on-device instead of moving T x n_vocab f32 per round), and
889    /// device slices carry no stream affinity: the caller resumes on the PRIMARY stream and
890    /// dereferences buffers whose producing kernels are still queued on the last stage's
891    /// stream. Nothing orders them.
892    ///
893    /// Why this only ever failed on ONE device: with stages on separate devices the caller's
894    /// first touch is a cross-device copy that the driver orders against the source context,
895    /// and the readback path syncs. Two streams on the SAME device genuinely overlap, so the
896    /// primary stream reads a buffer whose matmul has not run — nondeterministic garbage
897    /// (measured: NaN, 3155.677, and 2.87e-5 where the reference had -2.0048926), and it
898    /// poisons the NEXT arm in the same process because the corrupted KV persists. This is
899    /// the same class as the SLOT FIRST-USE ORDERING find above, one level up: there the
900    /// unordered pair was alloc-memset vs TX copy, here it is stage-N compute vs the
901    /// caller's consumer.
902    ///
903    /// Fix = the boundary law applied to the exit: record an event on the producing stage
904    /// stream, make the caller's stream wait on it. Event-wait, not a device sync, so the
905    /// stage streams keep running for the deferred-readback arm. Call INSIDE the last-stage
906    /// scope, after the last enqueue, with the caller's (pre-`enter`) stream.
907    pub fn publish_to(&self, s: usize, dst: &Arc<CudaStream>)
908                      -> Result<(), Box<dyn std::error::Error>> {
909        let st = &self.stages[s];
910        // Same stream (STREAMS=0 rollback, or a caller already on the stage stream): the
911        // stream orders itself; recording+waiting would be a no-op with a stray event.
912        if Arc::ptr_eq(&st.stream, dst) {
913            return Ok(());
914        }
915        let ev = st.ctx.new_event(None)?;
916        ev.record(&st.stream)?;
917        dst.wait(&ev)?;
918        Ok(())
919    }
920
921    /// REVERSE PUBLICATION (#87 root cause, lane/pp2spec-crash 2026-08-07): order every
922    /// STAGE stream behind the CALLER's stream — the mirror of `publish_to`.
923    ///
924    /// `publish_to` orders caller READS behind stage COMPUTE. Nothing ordered the other
925    /// direction: buffers ALLOCATED on a stage stream (the verify's returned logits/hidden,
926    /// the VerifyCkpt stashes) are CONSUMED by kernels the caller enqueues on the PRIMARY
927    /// stream, and when they drop, cudarc enqueues `free_async` on the ALLOCATING (stage)
928    /// stream. With event tracking elided (the decode-path default) the drop carries no
929    /// read-guard, so the pool can hand the block to the NEXT stage-stream allocation and
930    /// its writes overwrite memory the queued primary-stream consumer has not read yet.
931    /// Measured (research/pp2spec-crash-20260807): the spec round-seed read 13/4096 NaN =
932    /// the uninitialized-bits signature (P(NaN|random u32) ~ 1/256), clean by host re-read
933    /// time — a read-before-write race, fatal via the argmax-sentinel -> embed_gather MMU
934    /// fault, and gated on c>=2 because a backed-up primary stream widens the window.
935    ///
936    /// Fix law: before a ppN body enqueues NEW stage-stream work (allocations that may
937    /// reuse freed blocks), every stage stream waits the caller's stream at its current
938    /// point. All primary consumers of the previous round's stage-allocated buffers are
939    /// enqueued by then (single host thread), so reuse-writes land strictly after them.
940    /// Call at ppN-body ENTRY with the pre-`enter` caller stream. Door-shut configs never
941    /// build a PpNRt, so single-card behavior is untouched.
942    pub fn fence_stages_behind(&self, src: &Arc<CudaStream>)
943                               -> Result<(), Box<dyn std::error::Error>> {
944        let ev = src.context().new_event(None)?;
945        ev.record(src)?;
946        for st in &self.stages {
947            if Arc::ptr_eq(&st.stream, src) {
948                continue;
949            }
950            st.stream.wait(&ev)?;
951        }
952        Ok(())
953    }
954
955    /// Deferred readback: record a fresh completion event on the LAST stage's stream
956    /// (call after the step's logits matmul has been enqueued there).
957    pub fn record_done(&self) -> Result<CudaEvent, Box<dyn std::error::Error>> {
958        let last = &self.stages[self.stages.len() - 1];
959        let ev = last.ctx.new_event(None)?;
960        ev.record(&last.stream)?;
961        Ok(ev)
962    }
963
964    /// The dedicated readback stream (last stage's context).
965    pub fn readback_stream(&self) -> &Arc<CudaStream> {
966        &self.readback
967    }
968}
969
970/// M2 increment 3: a step's logits, still device-resident on the LAST stage. `wait()`
971/// orders the readback stream behind the step's completion event, copies, and syncs —
972/// tokens enqueued after this step keep running on the stage streams while the caller
973/// drains token t. Dropping without waiting is safe (buffers free stream-ordered).
974pub struct PendingLogits {
975    logits: CudaSlice<f32>,
976    ev: CudaEvent,
977    rb: Arc<CudaStream>,
978}
979
980impl PendingLogits {
981    pub fn new(logits: CudaSlice<f32>, ev: CudaEvent, rb: Arc<CudaStream>) -> Self {
982        PendingLogits { logits, ev, rb }
983    }
984
985    /// Blocks until this step's logits are computed, returns them host-side. Only this
986    /// step's work is waited on (event-ordered) — NOT later tokens already enqueued on
987    /// the stage streams.
988    pub fn wait(self) -> Result<Vec<f32>, Box<dyn std::error::Error>> {
989        self.rb.wait(&self.ev)?;
990        let host = self.rb.clone_dtoh(&self.logits)?;
991        self.rb.synchronize()?;
992        // logits drop AFTER the sync: the D2H has fully completed, so the stream-ordered
993        // free on the compute stream cannot race the copy.
994        Ok(host)
995    }
996}
997
998/// Stage-owned cache allocation door: when the ppN door is open AND `MEMRA_PP_DEVICES`
999/// is set (placement plumbing), each layer's cache is allocated by its OWNING stage's
1000/// engine — on one device this is byte-for-byte today's allocation (gated); cross-device
1001/// it puts each stage's KV on that stage's HBM. Door shut or devices unset: plain
1002/// `Cache::new` (zero behavior change). Trailing MTP/NextN layers (beyond the trunk)
1003/// map to the LAST stage.
1004pub fn new_cache(e: &Engine, cfg: &memra_gguf::config::ModelConfig, max_ctx: usize)
1005                 -> Result<crate::cache::Cache, Box<dyn std::error::Error>> {
1006    let n_trunk = (cfg.n_layer - cfg.nextn_predict_layers) as usize;
1007    if let Some(fence) = pp_cuts(n_trunk) {
1008        if pp2_devices_env().is_some() && !pp2_streams_off() {
1009            let rt = PpNRt::get(e)?;
1010            let n_st = fence.len() - 1;
1011            assert_eq!(
1012                rt.n_stages(), n_st,
1013                "PpNRt stage count {} != fence stages {n_st}", rt.n_stages()
1014            );
1015            // #87 REVERSE PUBLICATION at ADMISSION (lane/pp2spec-crash): this is the one
1016            // stage-stream allocation site OUTSIDE the ppN step bodies — a NEW session's
1017            // KV alloc_zeros enqueue on the STAGE streams, and their pool blocks can be
1018            // reuse of buffers freed from ANOTHER session's in-flight verify whose
1019            // primary-stream reads are still queued (the c=2 residual: exactly one trap
1020            // per admission collision, round 0, after the step-body fences landed).
1021            // Order the stage streams behind the caller before the memsets can clobber.
1022            // Anatomy: `PpNRt::fence_stages_behind`.
1023            rt.fence_stages_behind(&e.stream())?;
1024            let devs: Vec<&dyn memra_kv::KvDev> =
1025                (0..n_st).map(|s| rt.engine(s, e) as &dyn memra_kv::KvDev).collect();
1026            let cache = crate::cache::Cache::new_ppn(&devs, &fence, cfg, max_ctx)?;
1027            sync_stages_after_load(e, n_trunk)?;
1028            return Ok(cache);
1029        }
1030        if !pp2_streams_off() {
1031            // CACHE BIRTH BARRIER (2026-08-02 pipelined-arm residual race): with the door
1032            // open but no device placement, Cache::new's alloc_zeros memsets enqueue on
1033            // the PRIMARY worker stream while the first KV appends / recurrent-state
1034            // reads run on the per-stage streams — no event orders them, and under
1035            // deferred readback the stage streams are hot immediately (a memset tail
1036            // can zero an already-appended KV row; intermittent, ~1-in-3 gate FAIL).
1037            // One context-sync per cache creation kills the class.
1038            let cache = crate::cache::Cache::new(e, cfg, max_ctx)?;
1039            sync_stages_after_load(e, n_trunk)?;
1040            return Ok(cache);
1041        }
1042    }
1043    crate::cache::Cache::new(e, cfg, max_ctx)
1044}
1045
1046/// M2 increment 2 LOAD BARRIER: weight uploads and decode-mirror builds enqueue on the
1047/// loading engines' WORKER streams; the first consumer launches on a DIFFERENT stream
1048/// with no load->decode event — the door-off reference walk on the primary worker
1049/// stream (sharded load: remote builds still in flight), or a fresh per-stage stream.
1050/// The 2026-08-02 gate finds (n2-dev01 step-0 168k-logit graze; split5 ref=0.0 head —
1051/// a half-built rp4 mirror — poisoning step-0 KV and every later step): one
1052/// context-wide synchronize per stage at load end kills the class. No-op when the door
1053/// is shut at load (single-stream load+decode is ordered by the stream itself).
1054pub fn sync_stages_after_load(e: &Engine, n_trunk: usize)
1055                              -> Result<(), Box<dyn std::error::Error>> {
1056    if pp2_streams_off() || pp_cuts(n_trunk).is_none() {
1057        return Ok(());
1058    }
1059    let rt = PpNRt::get(e)?;
1060    for s in 0..rt.n_stages() {
1061        rt.stages[s].ctx.bind_to_thread()?;
1062        unsafe {
1063            cudarc::driver::sys::cuCtxSynchronize().result()?;
1064        }
1065    }
1066    e.ctx().bind_to_thread()?;
1067    unsafe {
1068        cudarc::driver::sys::cuCtxSynchronize().result()?;
1069    }
1070    Ok(())
1071}
1072
1073/// M2 increment 2 (weight sharding): the engine that should UPLOAD layer `il`'s weights
1074/// (and build its decode mirrors) — the owning stage's engine when the door is open with
1075/// device placement and sharding not rolled back; else the primary. `il >= n_trunk`
1076/// (MTP/NextN blocks) maps to the last stage. The head (output_norm + lm head) belongs
1077/// to the last trunk layer's stage — call with `il = n_trunk - 1`.
1078pub fn layer_engine<'a>(e: &'a Engine, n_trunk: usize, il: usize)
1079                        -> Result<&'a Engine, Box<dyn std::error::Error>> {
1080    if pp_shard_off() || pp2_devices_env().is_none() || pp2_streams_off() {
1081        return Ok(e);
1082    }
1083    let Some(fence) = pp_cuts(n_trunk) else { return Ok(e) };
1084    let rt = PpNRt::get(e)?;
1085    let s = stage_of(&fence, il.min(n_trunk - 1));
1086    Ok(rt.engine(s, e))
1087}