spark_model/layers/ops/dispatch_helpers.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! GEMM-path dispatch helpers + roofline instrumentation. Extracted from the
4//! `ops` module root during the ≤500-line split. Re-exported at
5//! `crate::layers::ops::*` via `ops.rs`.
6
7#![allow(unused_imports)]
8
9use super::*;
10
11// The nine GEMM-path flags that lived here as `OnceLock<bool>` statics are now
12// `layers::ops::GemmDispatch`, resolved once when the model is built and
13// carried on `ForwardContext`. A static outlived the model whose flags it
14// encoded — swap to a model with different levers and the process kept serving
15// the previous model's dispatch decisions, silently. It also hid the
16// dependency: a function reading the environment through a static takes no
17// argument that says so and gives the compiler nothing to check.
18
19use spark_runtime::gpu::GpuBackend;
20
21// The two BATCHED-PREFILL ADMISSION flags below are not GEMM-path dispatch and
22// have no `GemmDispatch` field; they gate whether concurrent prefills co-admit
23// into one forward. CODISPATCH is now a command-line flag on the same terms as
24// VARLEN below -- the flag→lever conversion those integration notes tracked.
25// Q12 stays env-only: it is the older spelling of the same path, kept for
26// recipes that predate the rename, and nothing measures it.
27
28/// The resolved CODISPATCH decision. ONE cell, FIVE readers: the three
29/// scheduler sites in `spark-server` (the admission window, the chunk-0 defer,
30/// and the shared-geometry guard) and the two batched-first-chunk sites in this
31/// crate. A `OnceLock` so the decision cannot change mid-serve.
32///
33/// ★ WHY IT BECAME A FLAG, 2026-09-22. `bench.yaml env:` is NODE-WIDE, so the
34/// env spelling arms every gate on a box at once. `concurrency-sweep` wants
35/// this lever and `ttft-warm-gate` cannot tolerate it: three control records at
36/// 0 agree to 0.045% (193.469 / 193.528 / 193.556 ms) while the one run at 1 is
37/// 202.760 ms -- +4.78%, about 107x that spread, against a +3.0% limit. Only a
38/// per-gate flag can give one gate the lever and deny it to the other.
39static PREFILL_CODISPATCH: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
40
41/// Publish the command line's `--prefill-codispatch` decision. Returns the
42/// value IN FORCE, which differs from `enabled` when something already resolved
43/// the cell (then the command line did NOT take effect -- the caller warns,
44/// mirroring `set_prefill_varlen_from_cli`). Absent flag ⇒ never called ⇒ the
45/// documented `AVAROK_PREFILL_CODISPATCH` fallback stays reachable.
46pub fn set_prefill_codispatch_from_cli(enabled: bool) -> bool {
47 let _ = PREFILL_CODISPATCH.set(enabled);
48 *PREFILL_CODISPATCH.get().expect("just set")
49}
50
51/// Cross-request co-dispatch of fresh prompts enabled? (`--prefill-codispatch`,
52/// legacy `AVAROK_PREFILL_CODISPATCH=1`; default OFF).
53///
54/// SSOT for all five readers. They must agree: the scheduler defers chunk-0 on
55/// this decision and the model layer then decides whether the batched path is
56/// eligible, so a disagreement strands streams mid-admission.
57pub fn prefill_codispatch_enabled() -> bool {
58 *PREFILL_CODISPATCH.get_or_init(|| {
59 bool_value_enabled(std::env::var("AVAROK_PREFILL_CODISPATCH").ok().as_deref())
60 })
61}
62
63/// Whether chunk-zero streams may use the paged batched-prefill path.
64///
65/// ★ THE OR MUST SURVIVE THE PROMOTION. In THIS crate codispatch is one of TWO
66/// ways to enable the same path, OR-ed with the older `AVAROK_Q12_BATCHED_FIRST_CHUNK`
67/// spelling; in `spark-server` it is a standalone scheduling switch. A 1:1
68/// replacement of the env read here would have silently dropped the Q12 alias
69/// and turned a path off for recipes that still set it. The two meanings are
70/// why this reads as an explicit OR rather than sharing the scheduler's call.
71pub fn prefill_batched_first_chunk_enabled() -> bool {
72 prefill_batched_first_chunk_from_parts(
73 prefill_codispatch_enabled(),
74 std::env::var("AVAROK_Q12_BATCHED_FIRST_CHUNK")
75 .ok()
76 .as_deref(),
77 )
78}
79
80/// The pure OR, kept separable so the rule is testable without touching a
81/// process-wide `OnceLock` or the environment. Note the ASYMMETRY that the
82/// promotion introduced and that the signature now states: codispatch arrives
83/// ALREADY RESOLVED (flag, else env), while Q12 is still a raw env spelling.
84fn prefill_batched_first_chunk_from_parts(codispatch: bool, q12: Option<&str>) -> bool {
85 codispatch || bool_value_enabled(q12)
86}
87
88/// The resolved VARLEN batched-prefill decision. One cell, three readers
89/// (admission predicate, batched-attention chunk-0 guard, scheduler wave
90/// planner) — a `OnceLock` so the decision cannot change mid-serve.
91static PREFILL_VARLEN: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
92
93/// Publish the command line's `--prefill-varlen-batch` decision. Returns the
94/// value IN FORCE, which differs from `enabled` when something already
95/// resolved the cell (then the command line did NOT take effect — the caller
96/// warns, mirroring `gdn_flags::set_from_cli`). Absent flag ⇒ never called ⇒
97/// the documented `AVAROK_PREFILL_VARLEN` fallback stays reachable.
98pub fn set_prefill_varlen_from_cli(enabled: bool) -> bool {
99 let _ = PREFILL_VARLEN.set(enabled);
100 *PREFILL_VARLEN.get().expect("just set")
101}
102
103/// VARLEN (ragged) batched prefill enabled? (`--prefill-varlen-batch`,
104/// legacy `AVAROK_PREFILL_VARLEN=1`; default OFF).
105///
106/// SSOT for the admission predicate (`check_kernel_batched_eligible`), the
107/// batched-attention layer's chunk-0 guard, and the scheduler's prefill wave
108/// planner. Those must agree: if admission accepts a batch the layer then
109/// rejects, the bail happens mid-Phase-A with streams already mutated, and
110/// the per-stream fallback re-runs setup on dirty state.
111pub fn prefill_varlen_enabled() -> bool {
112 *PREFILL_VARLEN
113 .get_or_init(|| bool_value_enabled(std::env::var("AVAROK_PREFILL_VARLEN").ok().as_deref()))
114}
115
116fn bool_value_enabled(value: Option<&str>) -> bool {
117 matches!(value, Some("1")) || value.is_some_and(|value| value.eq_ignore_ascii_case("true"))
118}
119
120pub fn log_cutlass_nvfp4_route(gpu: &dyn GpuBackend, name: &str, m: u32, n: u32, k: u32) {
121 // Routing telemetry, not a warning: the dedup key includes M, and
122 // prefill produces a new M per token count, so at WARN this spammed the
123 // production channel on every agentic request (and a polluted WARN
124 // stream misdirects real investigations). Skip the dedup probe entirely
125 // unless a subscriber would take the debug event — this runs per routed
126 // GEMM call.
127 if !tracing::enabled!(tracing::Level::DEBUG) {
128 return;
129 }
130 // De-duplicated on the BACKEND (`OpCache::first_shape`), not in a static:
131 // the shapes a model dispatches are its own, and a process-wide set
132 // suppresses the first route line for every shape a previous model
133 // happened to use — the lines that say which kernel this model took.
134 if gpu.op_cache().first_shape(name, m, n, k) {
135 tracing::debug!("CUTLASS_NVFP4_ROUTE {name} M={m} N={n} K={k}");
136 }
137}
138
139/// Roofline instrumentation: log each unique (kernel, M, N, K) GEMM shape once,
140/// gated by `AVAROK_GEMM_SHAPE_LOG=1`. Used to cross-reference nsys per-call
141/// durations → achieved TFLOPS/bandwidth vs GB10 peak.
142pub fn log_gemm_shape(gpu: &dyn GpuBackend, name: &str, m: u32, n: u32, k: u32) {
143 if std::env::var("AVAROK_GEMM_SHAPE_LOG").ok().as_deref() != Some("1") {
144 return;
145 }
146 if gpu.op_cache().first_shape(name, m, n, k) {
147 let flop = 2.0 * m as f64 * n as f64 * k as f64;
148 tracing::warn!("GEMM_SHAPE {name} M={m} N={n} K={k} FLOP={flop:.3e}");
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use super::{bool_value_enabled, prefill_batched_first_chunk_from_parts};
155
156 #[test]
157 fn accepts_boolean_environment_spellings() {
158 assert!(bool_value_enabled(Some("1")));
159 assert!(bool_value_enabled(Some("true")));
160 assert!(bool_value_enabled(Some("TRUE")));
161 assert!(!bool_value_enabled(Some("0")));
162 assert!(!bool_value_enabled(Some("false")));
163 assert!(!bool_value_enabled(None));
164 }
165
166 /// ★ THE ASSERTION THAT CATCHES A 1:1 PROMOTION. When codispatch became a
167 /// command-line flag it stopped being an env read HERE too -- and the
168 /// tempting edit was to replace both env reads with the one resolved
169 /// decision. That would have dropped the Q12 alias silently, turning the
170 /// batched chunk-0 path OFF for every recipe that still sets the older
171 /// spelling and nothing but a production regression to say so. The third
172 /// case below is the one that fails if anyone does it.
173 #[test]
174 fn either_chunk_zero_spelling_enables_admission() {
175 // codispatch alone (the flag, or its env fallback, already resolved)
176 assert!(prefill_batched_first_chunk_from_parts(true, None));
177 // Q12 alone -- the alias that must survive the promotion
178 assert!(prefill_batched_first_chunk_from_parts(false, Some("true")));
179 assert!(prefill_batched_first_chunk_from_parts(false, Some("1")));
180 // neither
181 assert!(!prefill_batched_first_chunk_from_parts(false, None));
182 // explicit off on both spellings
183 assert!(!prefill_batched_first_chunk_from_parts(false, Some("0")));
184 assert!(!prefill_batched_first_chunk_from_parts(
185 false,
186 Some("false")
187 ));
188 // ★ codispatch OFF does not veto Q12: it is an OR, not a master switch.
189 // An `&&` here would read as "codispatch gates everything", which is
190 // what the scheduler means by the word and NOT what this crate does.
191 assert!(prefill_batched_first_chunk_from_parts(false, Some("1")));
192 }
193}