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}