spark_runtime/
flashinfer.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! Host-callable FlashInfer ragged/varlen prefill attention FFI (GB10/sm_121).
3//!
4//! ★ REFERENCE IMPLEMENTATION — A BENCHMARK TARGET, NOT A DEPENDENCY.
5//!
6//! Atlas ships its OWN kernels. FlashInfer is wrapped here for exactly one
7//! purpose: to be the opponent we measure against and beat. **Nothing in a
8//! default build or a default serve calls a single line of it.**
9//!
10//! Two independent gates keep that true, and BOTH must survive any edit:
11//!
12//!   1. COMPILE TIME — every item in this module is `#[cfg(atlas_flashinfer)]`, and
13//!      `build.rs` sets that cfg only when `FLASHINFER_HOME` is exported. A build
14//!      without it links no FlashInfer object at all.
15//!   2. RUNTIME — the dispatch arms are opt-in behind `ATLAS_FLASHINFER_PREFILL=1`.
16//!      The default is OFF.
17//!
18//! So the honest reading of an Atlas performance number is that Atlas kernels
19//! produced it, because a default binary cannot reach this code. Export the
20//! env var and you are measuring FlashInfer — label the number that way.
21//!
22//! ★ WHY KEEP IT COMPILED-BUT-DARK. Agentic benchmarking. An optimisation
23//! claim needs a credible opponent: "faster than our own previous commit" is
24//! a far weaker statement than "faster than FlashInfer on this shape". Keeping the
25//! wrapper one env var away lets any agent A/B a shape against the industry
26//! reference on the same box, same checkpoint, same stream — which is the
27//! only comparison worth quoting.
28//!
29//! Do NOT promote any of this to a default path. If a FlashInfer shape beats ours,
30//! the correct response is to make OUR kernel faster and re-measure.
31//!
32//! FlashInfer's `BatchPrefillWithRaggedKVCacheDispatched` is a FlashAttention-2
33//! SM80-class kernel (mma.sync/ldmatrix/cp.async) that codegens for sm_121f. We
34//! wrap it host-side exactly like the CUTLASS object: nvcc compiles
35//! `cuda/flashinfer_ragged_prefill.cu` to a static lib in `build.rs` (gated on
36//! `FLASHINFER_HOME`), this module declares the `extern "C"` ABI, and callers
37//! pass `u64` device pointers + a `cudaStream_t` as `u64`.
38//!
39//! Purpose: batch N requests' attention into ONE varlen launch (q_indptr/
40//! kv_indptr ragged offsets) so cross-request prefill scales — the missing
41//! piece behind Atlas's flat ~3880 tok/s prefill at any concurrency.
42
43use anyhow::{Result, bail};
44
45mod hd128;
46pub use hd128::ragged_prefill_bf16_hd128;
47
48#[cfg(atlas_flashinfer)]
49use std::ffi::c_void;
50#[cfg(atlas_flashinfer)]
51use std::sync::OnceLock;
52
53#[cfg(atlas_flashinfer)]
54unsafe extern "C" {
55    // Available for diagnostics; the wrapper uses fixed workspace budgets instead.
56    #[allow(dead_code)]
57    fn atlas_fi_ragged_prefill_workspace_sizes(
58        max_batch: u32,
59        max_total_qo_rows: u32,
60        num_qo_heads: u32,
61        num_kv_heads: u32,
62        head_dim: u32,
63        float_ws_bytes_out: *mut usize,
64        int_ws_bytes_out: *mut usize,
65        pinned_int_ws_bytes_out: *mut usize,
66    ) -> i32;
67
68    #[allow(clippy::too_many_arguments)]
69    fn atlas_fi_ragged_prefill_bf16_hd256(
70        q: *const c_void,
71        k: *const c_void,
72        v: *const c_void,
73        o: *mut c_void,
74        qo_indptr_h: *const i32,
75        kv_indptr_h: *const i32,
76        qo_indptr_d: *const i32,
77        kv_indptr_d: *const i32,
78        batch: u32,
79        total_qo_rows: u32,
80        total_kv_rows: u32,
81        num_qo_heads: u32,
82        num_kv_heads: u32,
83        head_dim: u32,
84        sm_scale: f32,
85        causal: i32,
86        float_ws: *mut c_void,
87        float_ws_bytes: usize,
88        int_ws: *mut c_void,
89        int_ws_bytes: usize,
90        pinned_int_ws: *mut c_void,
91        pinned_int_ws_bytes: usize,
92        stream: *mut c_void,
93    ) -> i32;
94
95    #[cfg(atlas_flashinfer)]
96    fn cuMemAlloc_v2(dptr: *mut u64, bytesize: usize) -> i32;
97    #[cfg(atlas_flashinfer)]
98    fn cudaHostAlloc(ptr: *mut *mut c_void, size: usize, flags: u32) -> i32;
99}
100
101/// Whether the FlashInfer wrapper was compiled in (FLASHINFER_HOME was set at build).
102pub fn available() -> bool {
103    cfg!(atlas_flashinfer)
104}
105
106// Persistent workspaces, sized for a generous max config and reused across calls
107// (FlashInfer plans into these each call; they don't carry state between calls).
108#[cfg(atlas_flashinfer)]
109struct Workspaces {
110    float_ws: u64,
111    int_ws: u64,
112    pinned_int_ws: u64,
113    float_sz: usize,
114    int_sz: usize,
115    pinned_sz: usize,
116}
117#[cfg(atlas_flashinfer)]
118unsafe impl Send for Workspaces {}
119#[cfg(atlas_flashinfer)]
120unsafe impl Sync for Workspaces {}
121#[cfg(atlas_flashinfer)]
122/// STATIC, DELIBERATELY — CUDA host. This is a workspace allocated in THE
123/// process CUDA context (see `atlas_core::cuda_host`, which establishes one
124/// per process) and sized by a fixed budget, not by any model's shapes: the
125/// bounds below are generous upper limits chosen to fit any realistic serving
126/// configuration, so a swap needs no reallocation and re-allocating per model
127/// would churn hundreds of megabytes for no change in what is mapped.
128///
129/// It survives a model swap for the same reason the context does. Nothing in
130/// it is derived from a model — no token ids, no weight pointers, no shapes —
131/// only scratch the library plans within.
132static WS: OnceLock<Workspaces> = OnceLock::new();
133
134// Max config the persistent workspaces are sized for. Generous upper bounds for
135// Holo serving (>= any realistic concurrent-prefill batch). num heads/head_dim
136// are fixed by the model.
137#[cfg(atlas_flashinfer)]
138const MAX_BATCH: u32 = 16;
139#[cfg(atlas_flashinfer)]
140const MAX_TOTAL_QO_ROWS: u32 = 16 * 16384;
141#[cfg(atlas_flashinfer)]
142const N_QO_HEADS: u32 = 16;
143#[cfg(atlas_flashinfer)]
144const N_KV_HEADS: u32 = 2;
145#[cfg(atlas_flashinfer)]
146const HEAD_DIM: u32 = 256;
147
148// FlashInfer's float workspace is a BUDGET PrefillPlan splits KV within (it
149// plans to fit, not a hard requirement) — vLLM uses ~128MB. The int/pinned
150// workspaces hold the scheduler metadata arrays (request/tile indices), bounded
151// by tile count. Fixed generous budgets; PrefillPlan adapts within them.
152#[cfg(atlas_flashinfer)]
153const FLOAT_WS_BYTES: usize = 256 << 20; // 256 MB
154#[cfg(atlas_flashinfer)]
155const INT_WS_BYTES: usize = 64 << 20; // 64 MB
156#[cfg(atlas_flashinfer)]
157const PINNED_WS_BYTES: usize = 64 << 20; // 64 MB
158
159#[cfg(atlas_flashinfer)]
160fn workspaces() -> Result<&'static Workspaces> {
161    if let Some(w) = WS.get() {
162        return Ok(w);
163    }
164    let _ = (
165        MAX_BATCH,
166        MAX_TOTAL_QO_ROWS,
167        N_QO_HEADS,
168        N_KV_HEADS,
169        HEAD_DIM,
170    );
171    let (fsz, isz, psz) = (FLOAT_WS_BYTES, INT_WS_BYTES, PINNED_WS_BYTES);
172    let mut float_ws = 0u64;
173    let mut int_ws = 0u64;
174    let mut pinned = std::ptr::null_mut::<c_void>();
175    unsafe {
176        let s1 = cuMemAlloc_v2(&mut float_ws, fsz.max(1));
177        if s1 != 0 {
178            bail!("cuMemAlloc FlashInfer float ws ({fsz}B) failed: {s1}");
179        }
180        let s2 = cuMemAlloc_v2(&mut int_ws, isz.max(1));
181        if s2 != 0 {
182            bail!("cuMemAlloc FlashInfer int ws ({isz}B) failed: {s2}");
183        }
184        let s3 = cudaHostAlloc(&mut pinned, psz.max(1), 0);
185        if s3 != 0 {
186            bail!("cudaHostAlloc FlashInfer pinned int ws ({psz}B) failed: {s3}");
187        }
188    }
189    let _ = WS.set(Workspaces {
190        float_ws,
191        int_ws,
192        pinned_int_ws: pinned as u64,
193        float_sz: fsz,
194        int_sz: isz,
195        pinned_sz: psz,
196    });
197    Ok(WS.get().unwrap())
198}
199
200/// Ragged batched prefill attention (BF16, head_dim=256, GQA, causal selectable).
201///
202/// `q`/`o`: `[total_qo_rows, num_qo_heads, 256]` BF16 device; `k`/`v`:
203/// `[total_kv_rows, num_kv_heads, 256]` BF16 device. `qo_indptr`/`kv_indptr` are
204/// `[batch+1]` int32 prefix-sum offsets — provided both on host (`*_h`, for the
205/// scheduler plan) and as device copies (`*_d`, read by the kernel).
206#[allow(clippy::too_many_arguments)]
207pub fn ragged_prefill_bf16_hd256(
208    q: u64,
209    k: u64,
210    v: u64,
211    o: u64,
212    qo_indptr_h: &[i32],
213    kv_indptr_h: &[i32],
214    qo_indptr_d: u64,
215    kv_indptr_d: u64,
216    batch: u32,
217    total_qo_rows: u32,
218    total_kv_rows: u32,
219    num_qo_heads: u32,
220    num_kv_heads: u32,
221    head_dim: u32,
222    sm_scale: f32,
223    causal: bool,
224    stream: u64,
225) -> Result<()> {
226    #[cfg(atlas_flashinfer)]
227    {
228        if head_dim != HEAD_DIM {
229            bail!("FlashInfer wrapper is head_dim=256 only (got {head_dim})");
230        }
231        if qo_indptr_h.len() != (batch + 1) as usize || kv_indptr_h.len() != (batch + 1) as usize {
232            bail!("indptr host slices must be batch+1 long");
233        }
234        let ws = workspaces()?;
235        let st = unsafe {
236            atlas_fi_ragged_prefill_bf16_hd256(
237                q as *const c_void,
238                k as *const c_void,
239                v as *const c_void,
240                o as *mut c_void,
241                qo_indptr_h.as_ptr(),
242                kv_indptr_h.as_ptr(),
243                qo_indptr_d as *const i32,
244                kv_indptr_d as *const i32,
245                batch,
246                total_qo_rows,
247                total_kv_rows,
248                num_qo_heads,
249                num_kv_heads,
250                head_dim,
251                sm_scale,
252                if causal { 1 } else { 0 },
253                ws.float_ws as *mut c_void,
254                ws.float_sz,
255                ws.int_ws as *mut c_void,
256                ws.int_sz,
257                ws.pinned_int_ws as *mut c_void,
258                ws.pinned_sz,
259                stream as *mut c_void,
260            )
261        };
262        if st != 0 {
263            bail!(
264                "FlashInfer ragged prefill failed: status {st} (batch={batch}, qo={total_qo_rows})"
265            );
266        }
267        Ok(())
268    }
269    #[cfg(not(atlas_flashinfer))]
270    {
271        let _ = (
272            q,
273            k,
274            v,
275            o,
276            qo_indptr_h,
277            kv_indptr_h,
278            qo_indptr_d,
279            kv_indptr_d,
280            batch,
281            total_qo_rows,
282            total_kv_rows,
283            num_qo_heads,
284            num_kv_heads,
285            head_dim,
286            sm_scale,
287            causal,
288            stream,
289        );
290        bail!("FlashInfer support was not built; set FLASHINFER_HOME when building")
291    }
292}
293
294#[cfg(all(test, atlas_flashinfer))]
295mod tests {
296    use super::*;
297    use std::ffi::c_void;
298
299    const H2D: i32 = 1;
300    const D2H: i32 = 2;
301    unsafe extern "C" {
302        fn cudaMalloc(p: *mut *mut c_void, n: usize) -> i32;
303        fn cudaFree(p: *mut c_void) -> i32;
304        fn cudaMemcpy(d: *mut c_void, s: *const c_void, n: usize, k: i32) -> i32;
305        fn cudaDeviceSynchronize() -> i32;
306    }
307    fn f32_to_bf16(x: f32) -> u16 {
308        let b = x.to_bits();
309        ((b + 0x7fff + ((b >> 16) & 1)) >> 16) as u16
310    }
311    fn bf16_to_f32(x: u16) -> f32 {
312        f32::from_bits((x as u32) << 16)
313    }
314    unsafe fn dev<T>(data: &[T]) -> u64 {
315        let bytes = std::mem::size_of_val(data);
316        let mut p = std::ptr::null_mut();
317        assert_eq!(unsafe { cudaMalloc(&mut p, bytes.max(1)) }, 0);
318        assert_eq!(
319            unsafe { cudaMemcpy(p, data.as_ptr() as *const c_void, bytes, H2D) },
320            0
321        );
322        p as u64
323    }
324
325    #[test]
326    #[ignore = "requires a free CUDA device + FLASHINFER_HOME build"]
327    #[allow(clippy::needless_range_loop)] // numerical reference: index-parallel loops read clearest
328    fn flashinfer_ragged_prefill_matches_cpu_reference() {
329        // 2 ragged requests (6 + 10 tokens), GQA 4 qo / 2 kv heads, hd=256, causal.
330        const HD: usize = 256;
331        const NQO: usize = 4;
332        const NKV: usize = 2;
333        let lens = [6usize, 10usize];
334        let qo_indptr: Vec<i32> = {
335            let mut v = vec![0i32];
336            for &l in &lens {
337                v.push(v.last().unwrap() + l as i32);
338            }
339            v
340        };
341        let kv_indptr = qo_indptr.clone();
342        let total: usize = lens.iter().sum();
343        let sm_scale = 1.0f32 / (HD as f32).sqrt();
344
345        // Deterministic pseudo-random bf16 inputs.
346        let rnd = |seed: u64| -> f32 {
347            let mut x = seed.wrapping_mul(0x9E3779B97F4A7C15).wrapping_add(1);
348            x ^= x >> 31;
349            x = x.wrapping_mul(0xBF58476D1CE4E5B9);
350            ((x >> 40) as f32 / (1u64 << 24) as f32 - 0.5) * 0.5
351        };
352        let q: Vec<u16> = (0..total * NQO * HD)
353            .map(|i| f32_to_bf16(rnd(i as u64)))
354            .collect();
355        let k: Vec<u16> = (0..total * NKV * HD)
356            .map(|i| f32_to_bf16(rnd(i as u64 ^ 0x1111)))
357            .collect();
358        let v: Vec<u16> = (0..total * NKV * HD)
359            .map(|i| f32_to_bf16(rnd(i as u64 ^ 0x2222)))
360            .collect();
361        let mut o = vec![0u16; total * NQO * HD];
362
363        let (q_d, k_d, v_d, o_d, qo_d, kv_d);
364        unsafe {
365            q_d = dev(&q);
366            k_d = dev(&k);
367            v_d = dev(&v);
368            o_d = dev(&o);
369            qo_d = dev(&qo_indptr);
370            kv_d = dev(&kv_indptr);
371        }
372
373        ragged_prefill_bf16_hd256(
374            q_d,
375            k_d,
376            v_d,
377            o_d,
378            &qo_indptr,
379            &kv_indptr,
380            qo_d,
381            kv_d,
382            lens.len() as u32,
383            total as u32,
384            total as u32,
385            NQO as u32,
386            NKV as u32,
387            HD as u32,
388            sm_scale,
389            true,
390            0,
391        )
392        .unwrap();
393        unsafe {
394            assert_eq!(cudaDeviceSynchronize(), 0);
395            assert_eq!(
396                cudaMemcpy(
397                    o.as_mut_ptr() as *mut c_void,
398                    o_d as *const c_void,
399                    o.len() * 2,
400                    D2H
401                ),
402                0
403            );
404        }
405
406        // CPU reference: per-request causal GQA attention.
407        let group = NQO / NKV;
408        let qf = |r: usize, h: usize, d: usize| bf16_to_f32(q[(r * NQO + h) * HD + d]);
409        let kf = |r: usize, kh: usize, d: usize| bf16_to_f32(k[(r * NKV + kh) * HD + d]);
410        let vf = |r: usize, kh: usize, d: usize| bf16_to_f32(v[(r * NKV + kh) * HD + d]);
411        let mut max_rel = 0.0f64;
412        let mut worst_cos = 1.0f64;
413        for (b, &len) in lens.iter().enumerate() {
414            let start = qo_indptr[b] as usize;
415            for qi in 0..len {
416                for h in 0..NQO {
417                    let kh = h / group;
418                    let mut scores = vec![0f32; qi + 1];
419                    for j in 0..=qi {
420                        let mut s = 0.0f32;
421                        for d in 0..HD {
422                            s += qf(start + qi, h, d) * kf(start + j, kh, d);
423                        }
424                        scores[j] = s * sm_scale;
425                    }
426                    let mx = scores.iter().cloned().fold(f32::MIN, f32::max);
427                    let mut den = 0.0f32;
428                    for s in &mut scores {
429                        *s = (*s - mx).exp();
430                        den += *s;
431                    }
432                    let mut out_ref = vec![0f32; HD];
433                    for (j, &p) in scores.iter().enumerate() {
434                        let w = p / den;
435                        for d in 0..HD {
436                            out_ref[d] += w * vf(start + j, kh, d);
437                        }
438                    }
439                    let mut dot = 0.0f64;
440                    let mut na = 0.0f64;
441                    let mut nb = 0.0f64;
442                    for d in 0..HD {
443                        let g = bf16_to_f32(o[(start + qi) * NQO * HD + h * HD + d]) as f64;
444                        let r = out_ref[d] as f64;
445                        dot += g * r;
446                        na += g * g;
447                        nb += r * r;
448                        max_rel = max_rel.max((g - r).abs() / (r.abs() + 1e-3));
449                    }
450                    let cos = dot / (na.sqrt() * nb.sqrt() + 1e-12);
451                    worst_cos = worst_cos.min(cos);
452                }
453            }
454        }
455        unsafe {
456            for p in [q_d, k_d, v_d, o_d, qo_d, kv_d] {
457                cudaFree(p as *mut c_void);
458            }
459        }
460        tracing::debug!("FLASHINFER_RAGGED worst_cos={worst_cos:.6} max_rel={max_rel:.4}");
461        assert!(
462            worst_cos > 0.99,
463            "FlashInfer ragged prefill diverges from CPU ref: cos {worst_cos}"
464        );
465    }
466}