Skip to main content

memra_engine/
spec_phase.rs

1//! Per-burst phase attribution for speculative rounds (`MEMRA_SPEC_TRACE`, generalized
2//! lane/glm5-extract-general from the glm5 loop's `MEMRA_GLM5_SPEC_TRACE` — the alias
3//! stays honored). The draft / verify / accept / rollback / source-maintenance split is
4//! SPEC-FAMILY-GENERIC: any spec loop owns those five boundaries, and the level-2 verify
5//! sub-split buckets are MIXER-CLASS buckets (KDA, MLA — multi-family classes), not one
6//! model's. The emit TAGS are the caller's, so a family's banked receipts keep their
7//! exact grep shape (`[glm5-phase]` / `[glm5-phase-v]` for the glm5 loop).
8//!
9//! DEFAULT OFF BY DESIGN (the flag row's law): each phase boundary SYNCHRONIZES the
10//! stream so device time lands in the right bucket, which serializes the round — a
11//! diagnostic instrument, never a serving mode, and its numbers are phase SHARES, not
12//! round walls (the un-traced round overlaps what the trace separates).
13
14use crate::Engine;
15use std::sync::atomic::Ordering;
16
17/// `MEMRA_SPEC_TRACE=1` (or the glm5 alias): per-burst phase attribution is on.
18pub fn spec_trace_on() -> bool {
19    spec_trace_level() >= 1
20}
21
22/// Trace LEVEL: `1` = the per-burst phase lines (draft/verify/accept/roll/maint);
23/// `2` = additionally the VERIFY sub-split — batched-class vs sequential-class time per
24/// burst (vkda with its in-kernel scan share, vmla, vrest = glue+FFN+head). Level 2 adds
25/// per-layer stream drains on top of level 1's phase drains: shares, never walls, never
26/// a perf row (the standing trace law). Read once per process (the worker chunk-policy
27/// pattern). The general name wins when both names are set to DIFFERENT levels — with
28/// one loud stderr line naming the override (the alias is never silently dead).
29pub fn spec_trace_level() -> u8 {
30    use std::sync::OnceLock;
31    static L: OnceLock<u8> = OnceLock::new();
32    *L.get_or_init(|| {
33        spec_trace_level_from(
34            std::env::var("MEMRA_SPEC_TRACE").ok().as_deref(),
35            std::env::var("MEMRA_GLM5_SPEC_TRACE").ok().as_deref(),
36        )
37    })
38}
39
40fn parse_level(v: Option<&str>) -> Option<u8> {
41    match v {
42        Some("1") => Some(1),
43        Some("2") => Some(2),
44        _ => None,
45    }
46}
47
48/// Pure resolution over the general name and the glm5 alias (unit-tested without env
49/// mutation). Either name alone is honored; both set and disagreeing = the general name
50/// wins LOUDLY (one stderr line naming both values).
51fn spec_trace_level_from(general: Option<&str>, glm5_alias: Option<&str>) -> u8 {
52    let g = parse_level(general);
53    let a = parse_level(glm5_alias);
54    if let (Some(gv), Some(av)) = (g, a)
55        && gv != av
56    {
57        eprintln!(
58            "[spec-trace] MEMRA_SPEC_TRACE={gv} overrides MEMRA_GLM5_SPEC_TRACE={av} \
59             (the general flag wins; unset one to silence this)"
60        );
61    }
62    g.or(a).unwrap_or(0)
63}
64
65/// Trace-level-2 verify sub-phase accumulators (ns), drained by [`SpecPhaseNs::emit`].
66/// Module-level atomics so the walk needs no signature plumbing through the ppN twin
67/// (the KDA_FUSED6_DISPATCHES precedent); level 2 is a single-session instrument, so
68/// cross-session interleaving is out of scope by definition.
69pub(crate) static V_KDA_NS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
70pub(crate) static V_KDA_SCAN_NS: std::sync::atomic::AtomicU64 =
71    std::sync::atomic::AtomicU64::new(0);
72pub(crate) static V_MLA_NS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
73/// FFN-branch share of the verify walk (lane/glm5-vrest): MoE + dense + shexp time inside
74/// vrest, so the box window can split the vrest bucket without re-deriving it. Ticks only
75/// on the batched arm, like its siblings; vrest's own definition (verify - vkda - vmla)
76/// stays unchanged for cross-window comparability — the line prints vffn INSIDE vrest.
77pub(crate) static V_FFN_NS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
78
79/// Per-burst phase counters (ns) — the verify-toll dataset the dspark loop banks under
80/// its `stats` clocks (`ns_draft/ns_verify/...`, dflash.rs).
81#[derive(Default)]
82pub(crate) struct SpecPhaseNs {
83    pub(crate) draft: u64,
84    pub(crate) verify: u64,
85    pub(crate) accept: u64,
86    pub(crate) roll: u64,
87    pub(crate) maint: u64,
88    pub(crate) rounds: u64,
89}
90
91impl SpecPhaseNs {
92    /// Fold another accumulator in (the per-round depth log feeding the per-burst trace).
93    pub(crate) fn add(&mut self, o: &SpecPhaseNs) {
94        self.draft += o.draft;
95        self.verify += o.verify;
96        self.accept += o.accept;
97        self.roll += o.roll;
98        self.maint += o.maint;
99        self.rounds += o.rounds;
100    }
101
102    /// Phase-boundary clock: drain the engines' streams so the elapsed time since the last
103    /// clock is attributable to the phase that just ran (the dspark `clock(stats, e)`
104    /// contract). `eh` == `e` when the ppN door is shut; under a split the verify walk's own
105    /// terminal drain already covers the stage streams transitively, so syncing the primary
106    /// and head streams here bounds every phase that runs on them.
107    pub(crate) fn clock(e: &Engine, eh: &Engine) -> std::time::Instant {
108        let _ = e.stream().synchronize();
109        if !std::ptr::eq(e, eh) {
110            let _ = eh.stream().synchronize();
111        }
112        std::time::Instant::now()
113    }
114
115    /// One line per burst under `tag`; the level-2 verify sub-split under `tag_v` — both
116    /// tags belong to the CALLING family so its banked receipts keep their grep shape.
117    pub(crate) fn emit(&self, tag: &str, tag_v: &str, k: usize) {
118        if self.rounds == 0 {
119            return;
120        }
121        let ms = |ns: u64| ns as f64 / 1e6;
122        let per = |ns: u64| ns as f64 / 1e6 / self.rounds as f64;
123        let total = self.draft + self.verify + self.accept + self.roll + self.maint;
124        eprintln!(
125            "[{tag}] rounds={} k={k} total={:.2}ms | draft={:.2} verify={:.2} \
126             accept={:.2} roll={:.2} maint={:.2} | per-round ms: draft={:.3} verify={:.3} \
127             accept={:.3} roll={:.3} maint={:.3} total={:.3}",
128            self.rounds,
129            ms(total),
130            ms(self.draft),
131            ms(self.verify),
132            ms(self.accept),
133            ms(self.roll),
134            ms(self.maint),
135            per(self.draft),
136            per(self.verify),
137            per(self.accept),
138            per(self.roll),
139            per(self.maint),
140            per(total),
141        );
142        // Level-2 verify sub-split (lane/glm5-verify-batch): batched-class vs
143        // sequential-class shares. vrest = the verify phase minus the mixer buckets
144        // (hc glue + FFN/MoE + head); scan = the sequential KDA chain inside the
145        // batched call. Drained per burst so consecutive bursts stay comparable.
146        if spec_trace_level() >= 2 {
147            let vkda = V_KDA_NS.swap(0, Ordering::Relaxed);
148            let scan = V_KDA_SCAN_NS.swap(0, Ordering::Relaxed);
149            let vmla = V_MLA_NS.swap(0, Ordering::Relaxed);
150            let vffn = V_FFN_NS.swap(0, Ordering::Relaxed);
151            let vrest = self.verify.saturating_sub(vkda + vmla);
152            eprintln!(
153                "[{tag_v}] rounds={} k={k} | per-round ms: vkda={:.3} (scan={:.3}) \
154                 vmla={:.3} vrest={:.3} (vffn={:.3})",
155                self.rounds,
156                per(vkda),
157                per(scan),
158                per(vmla),
159                per(vrest),
160                per(vffn),
161            );
162        }
163    }
164}
165
166/// `MEMRA_SPEC_PROF=1` (lane/b200-spec-ttft-20260902): the ONCE-PER-REQUEST first-token
167/// phase profile for a served spec session — every phase between the session's prime and
168/// the first streamed token, in ms. Distinct from `MEMRA_SPEC_TRACE` (per-burst round
169/// SHARES): this instrument answers "where did the first-token latency go on THIS
170/// request", so it buckets the one-time costs the round trace cannot see (cache alloc,
171/// target prime, boundary draw, drafter KV alloc, the round-1 drafter prime over the
172/// prompt) and the first burst's wall. DEFAULT OFF BY DESIGN: phase boundaries synchronize
173/// the stream (the `SpecPhaseNs::clock` contract), so the traced first burst is a little
174/// slower than the untraced one; the line attributes, the untraced TTFT claims. Read once
175/// per process.
176pub fn spec_prof_on() -> bool {
177    use std::sync::OnceLock;
178    static ON: OnceLock<bool> = OnceLock::new();
179    *ON.get_or_init(|| std::env::var("MEMRA_SPEC_PROF").as_deref() == Ok("1"))
180}
181
182/// The first-token phase buckets (ms) one served spec session carries until the worker
183/// prints its `[spec-prof]` line after the first burst. Every field is a wall interval
184/// bounded by stream drains on both sides, so the buckets are device-inclusive and
185/// additive; a bucket the route never runs stays 0.0.
186#[derive(Default, Debug, Clone, PartialEq)]
187pub struct SpecFirstTokenProf {
188    /// Session creation: the trunk cache allocation (`pp::new_cache_planned`).
189    pub cache_alloc_ms: f64,
190    /// Session creation: the target prime over the prompt (`prime_cache`), INCLUDING the
191    /// host-staged tap DtoHs the DFlash2 sink takes inside the walk and the prime's own
192    /// full-vocab logits readback.
193    pub prime_ms: f64,
194    /// Session creation: the prompt-boundary prefix capture (0 with the prefix door shut).
195    pub capture_ms: f64,
196    /// Session creation: the boundary token draw (sampled: host logits HtoD + filtered
197    /// Gumbel + readback; greedy: host argmax).
198    pub anchor_ms: f64,
199    /// Session creation: the draft-source state build — DFlash2: `DflashKv::new` at the
200    /// session ctx (2 x n_layer x (ctx + block) x n_kv x head_dim x f32, uninit);
201    /// native MTP: the batched plane fill over the prompt.
202    pub draft_alloc_ms: f64,
203    /// Round 1: the DFlash2 drafter's ctx ingest of the WHOLE prompt's feature rows
204    /// (HtoD of the host-staged taps + `ctx_features` + per-layer k/v projections). The
205    /// only prompt-length-linear cost after the target prime. 0 on the native arm.
206    pub draft_prime_ms: f64,
207    /// Round 1: draft production after the ingest (block forward + lm_head over the
208    /// mask-fill rows + selector walk; native arm: the MTP chain).
209    pub first_draft_ms: f64,
210    /// Round 1: the t=K+1 verify walk.
211    pub first_verify_ms: f64,
212    /// Round 1: the accept walk (device argmaxes / rejection sampling + readbacks).
213    pub first_accept_ms: f64,
214    /// Round 1: the trunk rollback to the accepted prefix.
215    pub first_roll_ms: f64,
216    /// Round 1: draft-source maintenance (tap drain / plane reset + re-seed).
217    pub first_maint_ms: f64,
218    /// Round 1: tokens the round committed (j accepted drafts + the bonus).
219    pub first_round_tokens: usize,
220    /// First burst: wall from burst entry to return, no extra drains. Under the
221    /// round-cadence door (`MEMRA_SPEC_FIRST_TOKEN_EAGER`, default ON) the commit hook
222    /// runs INSIDE this window, so the per-slice detext + channel sends land here too;
223    /// `first_burst_hook_ms` is exactly that share — `first_burst_ms -
224    /// first_burst_hook_ms` is the engine-only wall of the burst on either arm.
225    pub first_burst_ms: f64,
226    /// First burst: time spent inside the caller's commit hook (0 with no hook, i.e.
227    /// `MEMRA_SPEC_FIRST_TOKEN_EAGER=0`). Host-only work: detext + `Event::Token` sends.
228    pub first_burst_hook_ms: f64,
229    /// First burst: rounds it ran and tokens it returned (anchor included).
230    pub first_burst_rounds: usize,
231    pub first_burst_tokens: usize,
232    // ---- depth attribution (lane/spec-route-depth-20260902) ----
233    /// Session creation: the prime tap sink's HOST allocation (`HcTapSink::new`, a
234    /// `[prompt, n_taps * hidden]` f32 Vec: 21 GB at 256k) — eager arm only.
235    pub sink_alloc_ms: f64,
236    /// Inside the target prime: the host-staged tap DtoHs (five synchronous readbacks per
237    /// prime chunk, accumulated by the walk) — eager arm only; a share of `prime_ms`.
238    pub prime_tap_dtoh_ms: f64,
239    /// Drafter prime split: host->device movement of the tap rows (pageable HtoD on the
240    /// eager arm; device slot DtoH into pinned + host interleave + async HtoD on the
241    /// chunked arm), the fc feature GEMM (`ctx_features`), the 5-layer k/v ingest.
242    pub draft_prime_h2d_ms: f64,
243    pub draft_prime_feat_ms: f64,
244    pub draft_prime_kv_ms: f64,
245    /// Drafter prime geometry: rows ingested, chunks, and which arm ran
246    /// (`eager` = round-1 ingest in 256-row chunks from the host sink; `chunked` =
247    /// `MEMRA_GLM5_DRAFT_PRIME_V2`, ingest per prime chunk from device-staged taps).
248    pub draft_prime_rows: usize,
249    pub draft_prime_chunks: usize,
250    pub draft_prime_arm: &'static str,
251    /// The drafter ctx KV allocation at the session ctx, in MB (uninit; 2 x n_layer x
252    /// (ctx + block) x n_kv x head_dim x f32).
253    pub draft_kv_mb: f64,
254    /// Free device memory per device ordinal, before the trunk cache allocation and after
255    /// the drafter KV allocation — the graph-launch headroom guard and every pool-growth
256    /// path key on it, so a per-boot bimodality shows up here first.
257    pub free_mb_before: Vec<(usize, u64)>,
258    pub free_mb_after: Vec<(usize, u64)>,
259}
260
261/// Rounds the per-round depth log keeps per session (lane/spec-route-depth-20260902).
262pub const SPEC_PROF_ROUNDS: usize = 64;
263
264/// One verify round's attribution row (`MEMRA_SPEC_PROF=1`, first
265/// [`SPEC_PROF_ROUNDS`] rounds of a session). Phase buckets are drained like the trace's
266/// (shares under drains, so `wall_ms` is the round's traced wall); `k` is the drafted
267/// count that entered the verify (after the confidence gate), `j` the accepted drafts,
268/// `ctx` the trunk rows at round entry, `seq_rows` the verify rows that took the PER-ROW
269/// mixer arm instead of the batched one (0 = every layer batched — a non-zero count at
270/// depth names the slow-path suspect by itself).
271#[derive(Default, Debug, Clone, Copy, PartialEq)]
272pub struct SpecRoundProf {
273    pub wall_ms: f32,
274    pub draft_ms: f32,
275    pub verify_ms: f32,
276    pub accept_ms: f32,
277    pub rest_ms: f32,
278    pub k: u16,
279    pub j: u16,
280    pub ctx: u32,
281    pub seq_rows: u32,
282}
283
284/// The per-session round log behind `[spec-prof-rounds]` / `[spec-prof-summary]`.
285#[derive(Default, Debug)]
286pub struct SpecRoundsLog {
287    pub rounds: Vec<SpecRoundProf>,
288    /// Rows already handed to the printer (`fresh` returns the tail past it).
289    pub printed: usize,
290    pub summarized: bool,
291}
292
293impl SpecRoundsLog {
294    pub fn wants_more(&self) -> bool {
295        self.rounds.len() < SPEC_PROF_ROUNDS
296    }
297    pub fn push(&mut self, r: SpecRoundProf) {
298        if self.wants_more() {
299            self.rounds.push(r);
300        }
301    }
302    /// Rows not yet printed; marks them printed.
303    pub fn fresh(&mut self) -> &[SpecRoundProf] {
304        let from = self.printed;
305        self.printed = self.rounds.len();
306        &self.rounds[from..]
307    }
308    /// One-line summary over the logged rounds: acceptance, tokens per round, the wall
309    /// distribution (mean/min/median/max) and how many rounds sit past 1.5x the median
310    /// (a within-boot bimodality count), the verify mean, and the per-row-arm row total.
311    pub fn summary(&self) -> String {
312        let n = self.rounds.len();
313        if n == 0 {
314            return "rounds=0".to_string();
315        }
316        let nf = n as f64;
317        let k: f64 = self.rounds.iter().map(|r| r.k as f64).sum::<f64>();
318        let j: f64 = self.rounds.iter().map(|r| r.j as f64).sum::<f64>();
319        let mut walls: Vec<f32> = self.rounds.iter().map(|r| r.wall_ms).collect();
320        walls.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
321        let med = walls[n / 2];
322        let mean = walls.iter().map(|&w| w as f64).sum::<f64>() / nf;
323        let slow = walls.iter().filter(|&&w| w > 1.5 * med).count();
324        let verify_mean = self.rounds.iter().map(|r| r.verify_ms as f64).sum::<f64>() / nf;
325        let draft_mean = self.rounds.iter().map(|r| r.draft_ms as f64).sum::<f64>() / nf;
326        let seq: u64 = self.rounds.iter().map(|r| r.seq_rows as u64).sum();
327        format!(
328            "rounds={n} k_mean={:.2} j_mean={:.2} accept={:.3} tok_per_round={:.2} \
329             wall_ms mean={:.1} min={:.1} med={:.1} max={:.1} slow_rounds(>1.5x med)={slow} \
330             draft_mean={:.1} verify_mean={:.1} seq_rows_total={seq} ctx_first={} ctx_last={}",
331            k / nf,
332            j / nf,
333            if k > 0.0 { j / k } else { 0.0 },
334            (j + nf) / nf,
335            mean,
336            walls[0],
337            med,
338            walls[n - 1],
339            draft_mean,
340            verify_mean,
341            self.rounds[0].ctx,
342            self.rounds[n - 1].ctx,
343        )
344    }
345}
346
347/// Verify rows that took the PER-ROW mixer arm (lane/spec-route-depth-20260902):
348/// incremented by `glm5_verify_range` on the sequential loop, sampled per round by the
349/// depth log. Module-level atomic, the `V_KDA_NS` precedent (single-session instrument).
350pub(crate) static V_SEQ_ROWS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
351
352/// Phase clock for [`SpecFirstTokenProf`]: `lap` drains the engines' streams and returns
353/// the ms since the previous lap (or `start`). Only ever constructed with the profile on.
354pub(crate) struct ProfClock {
355    t: std::time::Instant,
356}
357
358impl ProfClock {
359    pub(crate) fn start(e: &Engine, eh: &Engine) -> Self {
360        Self {
361            t: SpecPhaseNs::clock(e, eh),
362        }
363    }
364    pub(crate) fn lap(&mut self, e: &Engine, eh: &Engine) -> f64 {
365        let now = SpecPhaseNs::clock(e, eh);
366        let ms = now.duration_since(self.t).as_secs_f64() * 1e3;
367        self.t = now;
368        ms
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::{parse_level, spec_trace_level_from};
375
376    #[test]
377    fn level_resolution_honors_both_names_general_wins() {
378        // off by default; junk values are off (the original match-arm law)
379        assert_eq!(spec_trace_level_from(None, None), 0);
380        assert_eq!(spec_trace_level_from(Some("x"), None), 0);
381        // either name alone
382        assert_eq!(spec_trace_level_from(Some("1"), None), 1);
383        assert_eq!(spec_trace_level_from(Some("2"), None), 2);
384        assert_eq!(spec_trace_level_from(None, Some("1")), 1);
385        assert_eq!(spec_trace_level_from(None, Some("2")), 2);
386        // agreement and (loud) general-wins disagreement
387        assert_eq!(spec_trace_level_from(Some("2"), Some("2")), 2);
388        assert_eq!(spec_trace_level_from(Some("1"), Some("2")), 1);
389        assert_eq!(spec_trace_level_from(Some("2"), Some("1")), 2);
390        // a junk general value never masks a valid alias
391        assert_eq!(spec_trace_level_from(Some("x"), Some("1")), 1);
392        assert_eq!(parse_level(Some("0")), None);
393    }
394}