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 (`warn_unwired_once` fires).
63
64use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
65use std::sync::{Arc, Mutex, OnceLock};
66
67use cudarc::driver::{CudaContext, CudaEvent, CudaSlice, CudaStream};
68
69use crate::Engine;
70
71/// Returns the stage fence iff the ppN door is open: `MEMRA_PP_STAGES=N` (N >= 2) with a
72/// valid cut list. The fence has N+1 entries: `[0, c1, .., cN-1, n_layers]`; stage s runs
73/// layers `[fence[s], fence[s+1])`. Reads the environment on every call (gates toggle the
74/// door in-process); the cost is a few getenv per decode step, eager-loop noise.
75pub fn pp_cuts(n_layers: usize) -> Option<Vec<usize>> {
76    let n_st: usize = match std::env::var("MEMRA_PP_STAGES") {
77        Ok(v) if v.is_empty() || v == "0" || v == "1" => return None,
78        Ok(v) => match v.parse::<usize>() {
79            Ok(n) => n,
80            Err(_) => {
81                warn_bad_once(&format!("MEMRA_PP_STAGES={v} unparseable; door stays OFF"));
82                return None;
83            }
84        },
85        Err(_) => return None,
86    };
87    if n_st < 2 || n_st > n_layers {
88        warn_bad_once(&format!(
89            "MEMRA_PP_STAGES={n_st} outside [2, n_layers={n_layers}]; door stays OFF"
90        ));
91        return None;
92    }
93    let mut fence = Vec::with_capacity(n_st + 1);
94    fence.push(0usize);
95    if let Ok(s) = std::env::var("MEMRA_PP_SPLITS") {
96        let parts: Result<Vec<usize>, _> =
97            s.split(',').map(|p| p.trim().parse::<usize>()).collect();
98        match parts {
99            Ok(cuts) if cuts.len() == n_st - 1 => fence.extend(cuts),
100            _ => {
101                warn_bad_once(&format!(
102                    "MEMRA_PP_SPLITS={s} invalid (want {} comma-separated cuts); door stays OFF",
103                    n_st - 1
104                ));
105                return None;
106            }
107        }
108    } else if let Ok(v) = std::env::var("MEMRA_PP_SPLIT") {
109        // N=2 back-compat spelling. With N>2 a single split is ambiguous — fail the door
110        // loudly rather than guess (a silent even-split would fake a gate config).
111        if n_st != 2 {
112            warn_bad_once(&format!(
113                "MEMRA_PP_SPLIT={v} set with MEMRA_PP_STAGES={n_st}; use MEMRA_PP_SPLITS \
114                 for N>2 — door stays OFF"
115            ));
116            return None;
117        }
118        match v.parse::<usize>() {
119            Ok(c) => fence.push(c),
120            Err(_) => {
121                warn_bad_once(&format!("MEMRA_PP_SPLIT={v} unparseable; door stays OFF"));
122                return None;
123            }
124        }
125    } else {
126        for s in 1..n_st {
127            fence.push(s * n_layers / n_st);
128        }
129    }
130    fence.push(n_layers);
131    for w in fence.windows(2) {
132        if w[0] >= w[1] {
133            warn_bad_once(&format!(
134                "pp stage fence {fence:?} not strictly increasing over [0, {n_layers}]; \
135                 door stays OFF"
136            ));
137            return None;
138        }
139    }
140    Some(fence)
141}
142
143/// N=2 back-compat view of the door (the gemma4 arm and `pp2-gate` are 2-stage): `Some(cut)`
144/// iff the door is open with EXACTLY two stages.
145pub fn pp2_split(n_layers: usize) -> Option<usize> {
146    pp_cuts(n_layers).filter(|f| f.len() == 3).map(|f| f[1])
147}
148
149/// The stage that owns layer `il` under `fence` (see `pp_cuts`).
150pub fn stage_of(fence: &[usize], il: usize) -> usize {
151    debug_assert!(fence.len() >= 2);
152    match fence[1..fence.len() - 1].binary_search(&il) {
153        // fence[1..][k] == il means il is the FIRST layer of stage k+1
154        Ok(k) => k + 1,
155        Err(k) => k,
156    }
157}
158
159/// MEMRA_PP_STREAMS=0: rollback to the increment-1 same-stream seam (boundary = two plain
160/// dtod copies on the ambient compute stream, no per-stage streams/events/devices).
161pub fn pp2_streams_off() -> bool {
162    matches!(std::env::var("MEMRA_PP_STREAMS").as_deref(), Ok("0"))
163}
164
165/// True iff the ppN door would put TWO OR MORE stage streams on ONE device (devices
166/// unset = all stages on the primary; or an explicit placement with a repeated device).
167/// The deferred-readback (pipelined) arm is REFUSED in this regime: the 2026-08-02 x20
168/// soak record — singledev pipelined 13/20 PASS default, 7 failures each diverging at a
169/// different step (timing-race signature); MEMRA_PDL=0 went 20/20 on one soak but a
170/// second same-config soak on the auto-gated build failed 2/20 (n2) and battery-4 failed
171/// n4 — so PDL narrows the window without closing it, and the true root cause (same
172/// Engine kernels concurrent on two streams of one device) is NOT fixed by any flag yet.
173/// Cross-device pipelined (one stage stream per device) is 23/23 clean post-fix. Refuse
174/// loudly rather than return silently-wrong logits. Env-only read (callable pre-runtime).
175pub fn pp_multi_stream_same_device() -> bool {
176    let stages_open = std::env::var("MEMRA_PP_STAGES")
177        .map(|v| v.parse::<usize>().map(|n| n >= 2).unwrap_or(false))
178        .unwrap_or(false);
179    let devices = std::env::var("MEMRA_PP_DEVICES").ok().filter(|v| !v.is_empty());
180    if (!stages_open && devices.is_none()) || pp2_streams_off() {
181        return false;
182    }
183    match devices {
184        None => true, // door open, no placement: every stage stream lands on the primary
185        Some(s) => {
186            let mut v: Vec<&str> = s.split(',').map(|p| p.trim()).collect();
187            let n = v.len();
188            v.sort_unstable();
189            v.dedup();
190            v.len() < n // repeated device = shared-device streams
191        }
192    }
193}
194
195/// MEMRA_PP_OVERLAP=1: alternate the double-buffered boundary slots per step (the
196/// pipelining seed). Default OFF — scheduling structure only, never math. Read per step
197/// so gates can A/B in-process.
198pub fn pp2_overlap() -> bool {
199    matches!(std::env::var("MEMRA_PP_OVERLAP").as_deref(), Ok("1"))
200}
201
202/// M2 increment 2 rollback seam: MEMRA_PP_SHARD=0 = the M1 bring-up placement (all
203/// weights upload through the primary engine; remote stages peer-read). Default ON —
204/// under MEMRA_PP_DEVICES each stage's layer range uploads through its own engine.
205pub fn pp_shard_off() -> bool {
206    matches!(std::env::var("MEMRA_PP_SHARD").as_deref(), Ok("0"))
207}
208
209/// Raw `MEMRA_PP_DEVICES` (parsed/validated at PpNRt build — a bad string must fail the
210/// decode step loudly, never silently fall back to same-device and fake a gate PASS).
211fn pp2_devices_env() -> Option<String> {
212    std::env::var("MEMRA_PP_DEVICES").ok().filter(|v| !v.is_empty())
213}
214
215static WARNED_BAD: AtomicBool = AtomicBool::new(false);
216fn warn_bad_once(msg: &str) {
217    if !WARNED_BAD.swap(true, Ordering::Relaxed) {
218        eprintln!("[pp] {msg}");
219    }
220}
221
222static WARNED_UNWIRED: AtomicBool = AtomicBool::new(false);
223/// One-time notice when the door is set but the executing path has no pp arm
224/// (M2 wires the generic eager decode at any N and the gemma4 eager arm at N=2).
225pub fn warn_unwired_once(path: &str) {
226    let open = std::env::var("MEMRA_PP_STAGES")
227        .map(|v| !v.is_empty() && v != "0" && v != "1")
228        .unwrap_or(false);
229    if open && !WARNED_UNWIRED.swap(true, Ordering::Relaxed) {
230        eprintln!(
231            "[pp] MEMRA_PP_STAGES set but `{path}` has no pp arm at this N; running unsplit"
232        );
233    }
234}
235
236// ======================================================================================
237//  PpNRt: the M2 transport runtime (per-stage streams, per-boundary events + slots)
238// ======================================================================================
239
240/// One pipeline stage's execution home: device, context, launch stream, and (for a stage
241/// remote to the primary engine's device) a dedicated Engine in that device's primary
242/// context (CUmodules are per-context).
243pub struct StageRt {
244    pub dev: usize,
245    pub ctx: Arc<CudaContext>,
246    pub stream: Arc<CudaStream>,
247    /// `Some` only when `dev` differs from the primary engine's device.
248    engine: Option<Engine>,
249}
250
251/// One boundary slot: a persistent RX-side buffer + its TX/RX completion events.
252/// PERSISTENT because the buffer is written by the TX stage's stream and read by the RX
253/// stage's: a per-step alloc/free would enqueue the free on ONE stream while the other
254/// might still be reading (the cross-stream free hazard) — a never-freed slot cannot race.
255struct BoundarySlot {
256    buf: Mutex<Option<CudaSlice<f32>>>,
257    /// Recorded on the TX stage's stream after the TX copy; RX waits on it. Created in
258    /// the TX stage's context (cuEventRecord requires event ctx == stream ctx).
259    ev_tx: CudaEvent,
260    /// Recorded on the RX stage's stream after the RX copy; the NEXT TX into this slot
261    /// waits on it (write-after-read guard). Created in the RX stage's context. Waiting
262    /// on a never-recorded event is a defined no-op, so step 0 needs no special case.
263    ev_rx: CudaEvent,
264}
265
266/// Boundary b sits between stage b (TX) and stage b+1 (RX). Two slots, alternating per
267/// step under MEMRA_PP_OVERLAP=1 (each boundary counts its own steps — a decode step
268/// crosses every boundary exactly once, so the counters stay in lockstep).
269struct BoundaryRt {
270    slots: [BoundarySlot; 2],
271    step: AtomicUsize,
272    /// true iff stage b and stage b+1 live on different devices (peer transport).
273    cross: bool,
274}
275
276pub struct PpNRt {
277    stages: Vec<StageRt>,
278    boundaries: Vec<BoundaryRt>,
279    /// true iff ANY boundary crosses devices.
280    cross_any: bool,
281    /// Dedicated readback stream in the LAST stage's context (deferred logits D2H —
282    /// waiting there instead of on the compute stream keeps later tokens enqueuable).
283    readback: Arc<CudaStream>,
284}
285
286/// M1 name kept alive for external callers (`pp-transport-smoke`, receipts, docs).
287pub type Pp2Rt = PpNRt;
288
289static RTN: OnceLock<Result<PpNRt, String>> = OnceLock::new();
290
291impl PpNRt {
292    /// The process-wide transport runtime, built on first use against the primary engine.
293    /// The stage count + device map freeze at first build (one config per process — gates
294    /// run one placement per invocation). Build errors are sticky and loud.
295    pub fn get(e: &Engine) -> Result<&'static PpNRt, Box<dyn std::error::Error>> {
296        RTN.get_or_init(|| Self::build(e).map_err(|err| err.to_string()))
297            .as_ref()
298            .map_err(|s| -> Box<dyn std::error::Error> { s.clone().into() })
299    }
300
301    fn build(e: &Engine) -> Result<PpNRt, Box<dyn std::error::Error>> {
302        let primary_dev = e.ctx().ordinal();
303        // Stage count: MEMRA_PP_DEVICES length wins when set (it IS the placement);
304        // else MEMRA_PP_STAGES; else 2 (the M1 default — pp-transport-smoke runs doorless).
305        let devices: Vec<usize> = match pp2_devices_env() {
306            Some(s) => {
307                let parts: Result<Vec<usize>, _> =
308                    s.split(',').map(|p| p.trim().parse::<usize>()).collect();
309                match parts {
310                    Ok(v) if v.len() >= 2 => v,
311                    _ => {
312                        return Err(format!(
313                            "MEMRA_PP_DEVICES={s} unparseable (want <d0>,..,<dN-1> e.g. 0,1,2,3)"
314                        )
315                        .into())
316                    }
317                }
318            }
319            None => {
320                let n_st = std::env::var("MEMRA_PP_STAGES")
321                    .ok()
322                    .and_then(|v| v.parse::<usize>().ok())
323                    .filter(|&n| n >= 2)
324                    .unwrap_or(2);
325                vec![primary_dev; n_st]
326            }
327        };
328        if let Ok(v) = std::env::var("MEMRA_PP_STAGES") {
329            if let Ok(n) = v.parse::<usize>() {
330                if n >= 2 && n != devices.len() {
331                    return Err(format!(
332                        "MEMRA_PP_DEVICES lists {} devices but MEMRA_PP_STAGES={n} — \
333                         refusing an ambiguous placement",
334                        devices.len()
335                    )
336                    .into());
337                }
338            }
339        }
340        let n_st = devices.len();
341        let cross_any = devices.iter().any(|&d| d != devices[0]);
342
343        // Every distinct device pair in use must peer-access BOTH ways: boundaries copy
344        // between consecutive stages, stage kernels may dereference primary-device weights
345        // (bring-up placement / MEMRA_PP_SHARD=0) and stage-0's pos_d upload.
346        let mut used: Vec<usize> = devices.clone();
347        used.push(primary_dev);
348        used.sort_unstable();
349        used.dedup();
350        if used.len() > 1 {
351            let n = cudarc::driver::result::device::get_count()? as usize;
352            for &d in &used {
353                if d >= n {
354                    return Err(format!(
355                        "MEMRA_PP_DEVICES={devices:?} but only {n} CUDA device(s) present"
356                    )
357                    .into());
358                }
359            }
360            for &a in &used {
361                for &b in &used {
362                    if a == b {
363                        continue;
364                    }
365                    let da = cudarc::driver::result::device::get(a as i32)?;
366                    let db = cudarc::driver::result::device::get(b as i32)?;
367                    let mut can: i32 = 0;
368                    unsafe {
369                        cudarc::driver::sys::cuDeviceCanAccessPeer(&mut can, da, db).result()?;
370                    }
371                    if can == 0 {
372                        return Err(format!(
373                            "device {a} cannot peer-access device {b} (cuDeviceCanAccessPeer=0); \
374                             ppN cross-device needs P2P — refusing a silently-staged path"
375                        )
376                        .into());
377                    }
378                }
379            }
380        }
381
382        // PER-STAGE ENGINE ISOLATION (2026-08-02 singledev pipelined find): Engine owns
383        // lazily-grown SHARED scratch pools (fa_part_pool, fa_vf16_scratch, argmax
384        // partials, ...) that are stable-pointer by design — safe on one stream, a data
385        // race the moment two stage streams run concurrently through the SAME Engine
386        // (deferred readback, >=2 tokens in flight: token t+1's stage-0 fa memsets the
387        // partials while token t's stage-s fa still reads them — the nondeterministic
388        // all-logits divergence; cross-device arms were immune because remote stages
389        // already got their own Engine). Every stage s>0 gets its OWN Engine even on the
390        // primary device: same CUcontext (primary retain), so the per-context CUmodule
391        // cache makes it cheap; scratch pools are per-Engine, so stages never share.
392        // Stage 0 keeps the primary engine (single-threaded host issue: the only
393        // concurrent user of `e` during a pp walk is stage 0 itself).
394        let mk_stage = |dev: usize, s: usize| -> Result<StageRt, Box<dyn std::error::Error>> {
395            if dev == primary_dev && s == 0 {
396                let ctx = e.ctx().clone();
397                let stream = ctx.new_stream()?;
398                Ok(StageRt { dev, ctx, stream, engine: None })
399            } else {
400                let eng = Engine::new(dev)?;
401                let ctx = eng.ctx().clone();
402                let stream = ctx.new_stream()?;
403                Ok(StageRt { dev, ctx, stream, engine: Some(eng) })
404            }
405        };
406        let mut stages = Vec::with_capacity(n_st);
407        for (s, &d) in devices.iter().enumerate() {
408            stages.push(mk_stage(d, s)?);
409        }
410
411        if used.len() > 1 {
412            // A context per distinct device (first stage that lives there; the primary's
413            // context for the primary device).
414            let ctx_of = |d: usize| -> &Arc<CudaContext> {
415                if d == primary_dev {
416                    e.ctx()
417                } else {
418                    &stages.iter().find(|s| s.dev == d).unwrap().ctx
419                }
420            };
421            // Enable peer access BOTH ways for every distinct pair (idempotent;
422            // ALREADY_ENABLED is success).
423            for &a in &used {
424                for &b in &used {
425                    if a == b {
426                        continue;
427                    }
428                    ctx_of(a).bind_to_thread()?;
429                    let rc = unsafe {
430                        cudarc::driver::sys::cuCtxEnablePeerAccess(ctx_of(b).cu_ctx(), 0)
431                    };
432                    use cudarc::driver::sys::cudaError_enum as E;
433                    if rc != E::CUDA_SUCCESS && rc != E::CUDA_ERROR_PEER_ACCESS_ALREADY_ENABLED {
434                        return Err(format!(
435                            "cuCtxEnablePeerAccess(dev{a} -> dev{b}) failed: {rc:?}"
436                        )
437                        .into());
438                    }
439                }
440            }
441            // MEM-POOL access grant (8x box 2026-08-02, M1 cross-device fix #2):
442            // cuCtxEnablePeerAccess does NOT map STREAM-ORDERED POOL allocations, and every
443            // engine buffer/weight goes through the device default pool (cuMemAllocAsync via
444            // cudarc; memra-runtime configures that pool). A stage kernel dereferencing
445            // another device's weights — or a boundary peer TX writing the RX slot — needs
446            // cuMemPoolSetAccess on the OWNING device's default pool for the ACCESSING
447            // device; without it the first remote dereference is CUDA_ERROR_ILLEGAL_ADDRESS
448            // (reported at the next API call in the poisoned context). Grant all pairs.
449            for &owner in &used {
450                for &accessor in &used {
451                    if owner == accessor {
452                        continue;
453                    }
454                    let dev = cudarc::driver::result::device::get(owner as i32)?;
455                    let mut pool: cudarc::driver::sys::CUmemoryPool = std::ptr::null_mut();
456                    unsafe {
457                        cudarc::driver::sys::cuDeviceGetDefaultMemPool(&mut pool, dev).result()?;
458                    }
459                    let desc = cudarc::driver::sys::CUmemAccessDesc {
460                        location: cudarc::driver::sys::CUmemLocation {
461                            type_: cudarc::driver::sys::CUmemLocationType::CU_MEM_LOCATION_TYPE_DEVICE,
462                            id: accessor as i32,
463                        },
464                        flags: cudarc::driver::sys::CUmemAccess_flags::CU_MEM_ACCESS_FLAGS_PROT_READWRITE,
465                    };
466                    let rc = unsafe { cudarc::driver::sys::cuMemPoolSetAccess(pool, &desc, 1) };
467                    if rc != cudarc::driver::sys::cudaError_enum::CUDA_SUCCESS {
468                        return Err(format!(
469                            "cuMemPoolSetAccess(dev{owner} pool -> dev{accessor}) failed: {rc:?}"
470                        )
471                        .into());
472                    }
473                }
474            }
475            // MEM-POOL access grant (8x box 2026-08-02, cross-device fix #2):
476            // cuCtxEnablePeerAccess does NOT map STREAM-ORDERED POOL allocations, and every
477            // engine buffer/weight goes through the device default pool (cuMemAllocAsync via
478            // cudarc; memra-runtime configures that pool). A stage-1 kernel dereferencing
479            // dev0 weights — or the stage-0 peer TX writing dev1's RX slot — needs
480            // cuMemPoolSetAccess on the OWNING device's default pool for the ACCESSING
481            // device; without it the first remote dereference is CUDA_ERROR_ILLEGAL_ADDRESS
482            // (reported at the next API call in the poisoned context). Grant both ways.
483            for (owner, accessor) in [(stages[0].dev, stages[1].dev), (stages[1].dev, stages[0].dev)] {
484                let dev = cudarc::driver::result::device::get(owner as i32)?;
485                let mut pool: cudarc::driver::sys::CUmemoryPool = std::ptr::null_mut();
486                unsafe {
487                    cudarc::driver::sys::cuDeviceGetDefaultMemPool(&mut pool, dev).result()?;
488                }
489                let desc = cudarc::driver::sys::CUmemAccessDesc {
490                    location: cudarc::driver::sys::CUmemLocation {
491                        type_: cudarc::driver::sys::CUmemLocationType::CU_MEM_LOCATION_TYPE_DEVICE,
492                        id: accessor as i32,
493                    },
494                    flags: cudarc::driver::sys::CUmemAccess_flags::CU_MEM_ACCESS_FLAGS_PROT_READWRITE,
495                };
496                let rc = unsafe { cudarc::driver::sys::cuMemPoolSetAccess(pool, &desc, 1) };
497                if rc != cudarc::driver::sys::cudaError_enum::CUDA_SUCCESS {
498                    return Err(format!(
499                        "cuMemPoolSetAccess(dev{owner} pool -> dev{accessor}) failed: {rc:?}"
500                    )
501                    .into());
502                }
503            }
504            // restore the primary context for the caller's subsequent work
505            e.ctx().bind_to_thread()?;
506            eprintln!(
507                "[pp] cross-device transport: {} (cudaMemcpyPeerAsync per cross boundary; \
508                 peer + default-pool access granted all pairs over {used:?}; weight home: {})",
509                devices
510                    .iter()
511                    .enumerate()
512                    .map(|(s, d)| format!("stage{s}=dev{d}"))
513                    .collect::<Vec<_>>()
514                    .join(" "),
515                if pp_shard_off() {
516                    format!("dev{primary_dev} (MEMRA_PP_SHARD=0 bring-up placement)")
517                } else {
518                    "per-stage (sharded loader)".to_string()
519                }
520            );
521        }
522
523        let mk_slot = |tx: &StageRt, rx: &StageRt| -> Result<BoundarySlot, Box<dyn std::error::Error>> {
524            Ok(BoundarySlot {
525                buf: Mutex::new(None),
526                ev_tx: tx.ctx.new_event(None)?,
527                ev_rx: rx.ctx.new_event(None)?,
528            })
529        };
530        let mut boundaries = Vec::with_capacity(n_st - 1);
531        for b in 0..n_st - 1 {
532            let (tx, rx) = (&stages[b], &stages[b + 1]);
533            boundaries.push(BoundaryRt {
534                slots: [mk_slot(tx, rx)?, mk_slot(tx, rx)?],
535                step: AtomicUsize::new(0),
536                cross: tx.dev != rx.dev,
537            });
538        }
539        let readback = stages[n_st - 1].ctx.new_stream()?;
540        Ok(PpNRt { stages, boundaries, cross_any, readback })
541    }
542
543    pub fn n_stages(&self) -> usize {
544        self.stages.len()
545    }
546
547    /// True iff any boundary crosses devices (transport = cudaMemcpyPeerAsync there).
548    pub fn cross_device(&self) -> bool {
549        self.cross_any
550    }
551
552    /// The engine a stage's subgraph must run through: the primary engine when the stage
553    /// lives on the primary device, else the stage's own (remote-context) engine.
554    pub fn engine<'a>(&'a self, s: usize, primary: &'a Engine) -> &'a Engine {
555        self.stages[s].engine.as_ref().unwrap_or(primary)
556    }
557
558    /// Enter stage `s`: until the guard drops, every engine op on this thread launches on
559    /// the stage's stream (memra_runtime ambient-stream override).
560    pub fn enter(&self, s: usize) -> memra_runtime::StreamOverride {
561        memra_runtime::push_stream_override(self.stages[s].stream.clone())
562    }
563
564    /// Boundary TX at boundary `b` (call within the stage-`b` scope; `x` = the
565    /// materialized [n] residual): wait for the slot's previous RX (write-after-read
566    /// guard), copy `x` into the slot's persistent buffer via the boundary's transport on
567    /// stage-b's stream (the owning-stream/publication law), record ev_tx. Returns the
568    /// slot index for the paired rx().
569    pub fn tx(&self, b: usize, x: &CudaSlice<f32>, n: usize)
570              -> Result<usize, Box<dyn std::error::Error>> {
571        assert_eq!(x.len(), n, "pp tx: residual length mismatch");
572        let bd = &self.boundaries[b];
573        let slot_idx = if pp2_overlap() {
574            bd.step.fetch_add(1, Ordering::Relaxed) % 2
575        } else {
576            0
577        };
578        let sl = &bd.slots[slot_idx];
579        let s_tx = &self.stages[b].stream;
580        s_tx.wait(&sl.ev_rx)?;
581        let mut guard = sl.buf.lock().unwrap();
582        if guard.as_ref().map(|bf| bf.len() != n).unwrap_or(true) {
583            // allocated on the RX stage's stream: the buffer lives on the RX device.
584            let s_rx = &self.stages[b + 1].stream;
585            *guard = Some(s_rx.alloc_zeros::<f32>(n)?);
586            // SLOT FIRST-USE ORDERING (2026-08-02 pipelined-gate find): the lazy alloc's
587            // pool-alloc + memset enqueue on the RX stream; the TX copy below issues on
588            // the TX stream, and on a slot's FIRST use ev_rx has never been recorded —
589            // nothing orders them. With >=2 tokens in flight the RX stream is still busy
590            // with the previous token, the memset lands AFTER the TX copy, and the
591            // boundary residual is zeroed (window=1 passed, window>=2 failed at the
592            // slot-1 first-use step; -overlap arms passed because the synchronous serial
593            // arm pre-warmed both slots). Host-sync the RX stream once per slot
594            // allocation — at most 2*(N-1) one-time syncs per process, all during prime.
595            s_rx.synchronize()?;
596        }
597        let buf = guard.as_mut().unwrap();
598        if !bd.cross {
599            s_tx.memcpy_dtod(x, buf)?;
600        } else {
601            // cudaMemcpyPeerAsync (M0: 2.8x NCCL at PP activation sizes), issued on the
602            // publishing TX stream with explicit src/dst contexts.
603            use cudarc::driver::{DevicePtr, DevicePtrMut};
604            let (sp, _g0) = x.device_ptr(s_tx);
605            let (dp, _g1) = buf.device_ptr_mut(s_tx);
606            self.stages[b].ctx.bind_to_thread()?;
607            unsafe {
608                cudarc::driver::result::memcpy_peer_async(
609                    self.stages[b + 1].ctx.cu_ctx(),
610                    dp,
611                    self.stages[b].ctx.cu_ctx(),
612                    sp,
613                    n * std::mem::size_of::<f32>(),
614                    s_tx.cu_stream(),
615                )?;
616            }
617        }
618        sl.ev_tx.record(s_tx)?;
619        Ok(slot_idx)
620    }
621
622    /// Boundary RX at boundary `b` (call within the stage-`b+1` scope): wait on the slot's
623    /// ev_tx, copy the boundary buffer into a fresh working buffer (dtod on the RX stream —
624    /// local on the RX device in both transports), record ev_rx. The returned buffer is
625    /// RX-stage-owned: allocated, consumed, and eventually freed on that stage's stream.
626    pub fn rx(&self, b: usize, slot_idx: usize, n: usize)
627              -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
628        let sl = &self.boundaries[b].slots[slot_idx];
629        let s_rx = &self.stages[b + 1].stream;
630        s_rx.wait(&sl.ev_tx)?;
631        let guard = sl.buf.lock().unwrap();
632        let buf = guard.as_ref().expect("pp rx before tx");
633        // uninit working buffer (fully overwritten by the copy), allocated explicitly on
634        // the stage stream so rx() is correct even outside an enter() scope.
635        let mut work = unsafe { s_rx.alloc::<f32>(n)? };
636        s_rx.memcpy_dtod(buf, &mut work)?;
637        sl.ev_rx.record(s_rx)?;
638        Ok(work)
639    }
640
641    /// Deferred readback: record a fresh completion event on the LAST stage's stream
642    /// (call after the step's logits matmul has been enqueued there).
643    pub fn record_done(&self) -> Result<CudaEvent, Box<dyn std::error::Error>> {
644        let last = &self.stages[self.stages.len() - 1];
645        let ev = last.ctx.new_event(None)?;
646        ev.record(&last.stream)?;
647        Ok(ev)
648    }
649
650    /// The dedicated readback stream (last stage's context).
651    pub fn readback_stream(&self) -> &Arc<CudaStream> {
652        &self.readback
653    }
654}
655
656/// M2 increment 3: a step's logits, still device-resident on the LAST stage. `wait()`
657/// orders the readback stream behind the step's completion event, copies, and syncs —
658/// tokens enqueued after this step keep running on the stage streams while the caller
659/// drains token t. Dropping without waiting is safe (buffers free stream-ordered).
660pub struct PendingLogits {
661    logits: CudaSlice<f32>,
662    ev: CudaEvent,
663    rb: Arc<CudaStream>,
664}
665
666impl PendingLogits {
667    pub fn new(logits: CudaSlice<f32>, ev: CudaEvent, rb: Arc<CudaStream>) -> Self {
668        PendingLogits { logits, ev, rb }
669    }
670
671    /// Blocks until this step's logits are computed, returns them host-side. Only this
672    /// step's work is waited on (event-ordered) — NOT later tokens already enqueued on
673    /// the stage streams.
674    pub fn wait(self) -> Result<Vec<f32>, Box<dyn std::error::Error>> {
675        self.rb.wait(&self.ev)?;
676        let host = self.rb.clone_dtoh(&self.logits)?;
677        self.rb.synchronize()?;
678        // logits drop AFTER the sync: the D2H has fully completed, so the stream-ordered
679        // free on the compute stream cannot race the copy.
680        Ok(host)
681    }
682}
683
684/// Stage-owned cache allocation door: when the ppN door is open AND `MEMRA_PP_DEVICES`
685/// is set (placement plumbing), each layer's cache is allocated by its OWNING stage's
686/// engine — on one device this is byte-for-byte today's allocation (gated); cross-device
687/// it puts each stage's KV on that stage's HBM. Door shut or devices unset: plain
688/// `Cache::new` (zero behavior change). Trailing MTP/NextN layers (beyond the trunk)
689/// map to the LAST stage.
690pub fn new_cache(e: &Engine, cfg: &memra_gguf::config::ModelConfig, max_ctx: usize)
691                 -> Result<crate::cache::Cache, Box<dyn std::error::Error>> {
692    let n_trunk = (cfg.n_layer - cfg.nextn_predict_layers) as usize;
693    if let Some(fence) = pp_cuts(n_trunk) {
694        if pp2_devices_env().is_some() && !pp2_streams_off() {
695            let rt = PpNRt::get(e)?;
696            let n_st = fence.len() - 1;
697            assert_eq!(
698                rt.n_stages(), n_st,
699                "PpNRt stage count {} != fence stages {n_st}", rt.n_stages()
700            );
701            let devs: Vec<&dyn memra_kv::KvDev> =
702                (0..n_st).map(|s| rt.engine(s, e) as &dyn memra_kv::KvDev).collect();
703            let cache = crate::cache::Cache::new_ppn(&devs, &fence, cfg, max_ctx)?;
704            sync_stages_after_load(e, n_trunk)?;
705            return Ok(cache);
706        }
707        if !pp2_streams_off() {
708            // CACHE BIRTH BARRIER (2026-08-02 pipelined-arm residual race): with the door
709            // open but no device placement, Cache::new's alloc_zeros memsets enqueue on
710            // the PRIMARY worker stream while the first KV appends / recurrent-state
711            // reads run on the per-stage streams — no event orders them, and under
712            // deferred readback the stage streams are hot immediately (a memset tail
713            // can zero an already-appended KV row; intermittent, ~1-in-3 gate FAIL).
714            // One context-sync per cache creation kills the class.
715            let cache = crate::cache::Cache::new(e, cfg, max_ctx)?;
716            sync_stages_after_load(e, n_trunk)?;
717            return Ok(cache);
718        }
719    }
720    crate::cache::Cache::new(e, cfg, max_ctx)
721}
722
723/// M2 increment 2 LOAD BARRIER: weight uploads and decode-mirror builds enqueue on the
724/// loading engines' WORKER streams; the first consumer launches on a DIFFERENT stream
725/// with no load->decode event — the door-off reference walk on the primary worker
726/// stream (sharded load: remote builds still in flight), or a fresh per-stage stream.
727/// The 2026-08-02 gate finds (n2-dev01 step-0 168k-logit graze; split5 ref=0.0 head —
728/// a half-built rp4 mirror — poisoning step-0 KV and every later step): one
729/// context-wide synchronize per stage at load end kills the class. No-op when the door
730/// is shut at load (single-stream load+decode is ordered by the stream itself).
731pub fn sync_stages_after_load(e: &Engine, n_trunk: usize)
732                              -> Result<(), Box<dyn std::error::Error>> {
733    if pp2_streams_off() || pp_cuts(n_trunk).is_none() {
734        return Ok(());
735    }
736    let rt = PpNRt::get(e)?;
737    for s in 0..rt.n_stages() {
738        rt.stages[s].ctx.bind_to_thread()?;
739        unsafe {
740            cudarc::driver::sys::cuCtxSynchronize().result()?;
741        }
742    }
743    e.ctx().bind_to_thread()?;
744    unsafe {
745        cudarc::driver::sys::cuCtxSynchronize().result()?;
746    }
747    Ok(())
748}
749
750/// M2 increment 2 (weight sharding): the engine that should UPLOAD layer `il`'s weights
751/// (and build its decode mirrors) — the owning stage's engine when the door is open with
752/// device placement and sharding not rolled back; else the primary. `il >= n_trunk`
753/// (MTP/NextN blocks) maps to the last stage. The head (output_norm + lm head) belongs
754/// to the last trunk layer's stage — call with `il = n_trunk - 1`.
755pub fn layer_engine<'a>(e: &'a Engine, n_trunk: usize, il: usize)
756                        -> Result<&'a Engine, Box<dyn std::error::Error>> {
757    if pp_shard_off() || pp2_devices_env().is_none() || pp2_streams_off() {
758        return Ok(e);
759    }
760    let Some(fence) = pp_cuts(n_trunk) else { return Ok(e) };
761    let rt = PpNRt::get(e)?;
762    let s = stage_of(&fence, il.min(n_trunk - 1));
763    Ok(rt.engine(s, e))
764}