spark_model/layers/ops/
gdn_flashinfer.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Opt-in FlashInfer GDN prefill via `dlopen(libatlasgdn.so)` — behind `ATLAS_GDN_FLASHINFER=1`.
4//!
5//! Bridges Atlas's native packed-QKV + interleaved gate/beta buffers to the AOT-exported
6//! FlashInfer chunked gated-delta-rule scan (tensor-core, ~11× the scalar FLA `chunk_delta_h`
7//! at the Holo shape — see `3rdparty_patches/gdn_aot/STATUS.md`). The C-ABI shim
8//! (`atlas_gdn_prefill_packed`) takes Atlas's exact native pointers: it deinterleaves
9//! gate/beta in-shim and reads q/k/v straight out of the packed buffer via `conv_dim`
10//! strides (no copy). Atlas's `gate` is already linear α (the kernel does the `logf`),
11//! so there is NO gate-space conversion.
12//!
13//! dlopen (not link-time) keeps this fully opt-in: the binary builds and runs without the
14//! library; it is only loaded when the flag is set. `ATLAS_GDN_LIB` overrides the path.
15use anyhow::{Result, anyhow, bail, ensure};
16use spark_runtime::gpu::{DevicePtr, GpuBackend};
17use std::os::raw::{c_char, c_float, c_int, c_void};
18use std::sync::OnceLock;
19
20// SAFETY: these two declarations must match libdl/glibc exactly, or every call
21// through them is UB. They do:
22//   void *dlopen(const char *filename, int flags);
23//   void *dlsym(void *handle, const char *symbol);
24// `c_char`/`c_int`/`*mut c_void` are the platform-correct spellings of
25// `char`/`int`/`void *`, and both are plain C functions with no variadics and no
26// callback arguments, so the C ABI mapping is total. They are declared here
27// rather than pulled from `libc` to keep this opt-in path dependency-free.
28unsafe extern "C" {
29    fn dlopen(filename: *const c_char, flag: c_int) -> *mut c_void;
30    fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void;
31}
32const RTLD_NOW: c_int = 2;
33
34// SAFETY (both fn types): these are the Rust spellings of the two `extern "C"`
35// entry points in `3rdparty_patches/gdn_aot/gdn_shim.cpp`, and the `transmute`s
36// in `lib()` are sound only while they match argument-for-argument:
37//
38//   void atlas_gdn_load();
39//   int  atlas_gdn_prefill_packed_managed(
40//            void* qkv, void* gate_beta, void* output, void* h_state,
41//            float scale, int total_seqlen, int nk, int nv, int kd, int vd,
42//            int conv_dim, int gb_stride, int num_seqs, void* stream);
43//
44// Verified against gdn_shim.cpp: 4 pointers, one float, eight ints in that
45// order, a trailing stream pointer, `int` return. If the shim's signature ever
46// changes, THESE TWO TYPES MUST CHANGE WITH IT — `dlsym` returns an untyped
47// `void*`, so nothing else in the toolchain will catch a mismatch.
48type LoadFn = unsafe extern "C" fn();
49// Managed entry: shim owns tensormaps/init/cu scratch (cached) — no per-call alloc/free/sync.
50type PackedFn = unsafe extern "C" fn(
51    *mut c_void, // qkv
52    *mut c_void, // gate_beta
53    *mut c_void, // output
54    *mut c_void, // h_state (output state)
55    c_float,     // scale
56    c_int,       // total_seqlen
57    c_int,       // nk
58    c_int,       // nv
59    c_int,       // kd
60    c_int,       // vd
61    c_int,       // conv_dim
62    c_int,       // gb_stride
63    c_int,       // num_seqs
64    *mut c_void, // stream
65) -> c_int;
66
67struct Lib {
68    prefill: PackedFn,
69}
70// SAFETY: the resolved fn pointers are process-global and immutable after load.
71// The `dlopen` handle they came from is DELIBERATELY LEAKED (see `lib()`): it is
72// never stored in a droppable value and `dlclose` is never called, so the
73// library's mapping — and therefore the code these pointers address — lives for
74// the whole process. There is no `Library` handle whose drop could unmap it out
75// from under a call.
76unsafe impl Send for Lib {}
77unsafe impl Sync for Lib {}
78
79/// STATIC, DELIBERATELY — process lifecycle. This is a `dlopen` handle and
80/// the fn pointers resolved from it. The dynamic loader keys on the SONAME,
81/// so a second `dlopen` of the same library returns the same handle and the
82/// same code: caching it per model would add bookkeeping without changing
83/// what is mapped. Nothing here is model-derived — the pointers are into a
84/// shared object, not into a registry that a swap unloads — and the `None`
85/// case (library absent) is a property of the machine, not of the model.
86static LIB: OnceLock<Option<Lib>> = OnceLock::new();
87
88fn lib() -> Option<&'static Lib> {
89    // SAFETY: the whole initialiser is one unsafe block; the obligations are
90    //   * `dlopen(cpath.as_ptr(), RTLD_NOW)` — `cpath` is a `CString` alive for
91    //     the entire call, so the pointer is a valid NUL-terminated C string.
92    //     RTLD_NOW (2) resolves every relocation up front, so a half-resolvable
93    //     library fails HERE rather than at first call.
94    //   * the returned handle is checked for null before either `dlsym`, and both
95    //     `dlsym` results are checked for null before either `transmute`.
96    //     `transmute`ing a null `*mut c_void` into a fn pointer and calling it
97    //     would be immediate UB, so those two checks are load-bearing.
98    //   * `transmute::<*mut c_void, _>` to `LoadFn`/`PackedFn` is sound only
99    //     because the shim's C signatures match those types — see the note on
100    //     the type aliases above, checked against gdn_shim.cpp.
101    //   * LIFETIME: the handle `h` is intentionally never `dlclose`d and never
102    //     escapes as a droppable value, so the mapping is leaked for the process
103    //     lifetime and the two fn pointers can never dangle. `OnceLock` runs this
104    //     at most once, so `atlas_gdn_load()` (which loads the cubin module onto
105    //     the device) is called exactly once, as the shim's `g_loaded` expects.
106    LIB.get_or_init(|| unsafe {
107        let path = std::env::var("ATLAS_GDN_LIB").unwrap_or_else(|_| "libatlasgdn.so".to_string());
108        let cpath = std::ffi::CString::new(path.clone()).ok()?;
109        let h = dlopen(cpath.as_ptr(), RTLD_NOW);
110        if h.is_null() {
111            tracing::warn!("ATLAS_GDN_FLASHINFER: dlopen('{path}') failed — falling back to FLA");
112            return None;
113        }
114        let load = dlsym(h, c"atlas_gdn_load".as_ptr());
115        let prefill = dlsym(h, c"atlas_gdn_prefill_packed_managed".as_ptr());
116        if load.is_null() || prefill.is_null() {
117            tracing::warn!("ATLAS_GDN_FLASHINFER: symbols not found in lib — falling back to FLA");
118            return None;
119        }
120        let load: LoadFn = std::mem::transmute(load);
121        load(); // load the cubin module onto the device(s) once
122        tracing::info!("ATLAS_GDN_FLASHINFER: FlashInfer GDN kernel loaded (opt-in)");
123        Some(Lib {
124            prefill: std::mem::transmute::<*mut c_void, PackedFn>(prefill),
125        })
126    })
127    .as_ref()
128}
129
130/// True when `ATLAS_GDN_FLASHINFER=1` AND the library + symbols loaded successfully.
131pub fn available() -> bool {
132    std::env::var("ATLAS_GDN_FLASHINFER").as_deref() == Ok("1") && lib().is_some()
133}
134
135/// Run one prefill GDN scan through the FlashInfer kernel on Atlas's native buffers.
136///
137/// `qkv`: packed `[Q(key_dim)|K(key_dim)|V(value_dim)]` bf16, row stride `conv_dim`.
138/// `gate_beta`: interleaved `[gate(nv)|beta(nv)]` fp32, row stride `gb_stride`.
139/// `output`: contiguous `[total, value_dim]` bf16. `h_state`: `[nv,kd,vd]` fp32 (final state out).
140/// Single-stream only (`num_seqs == 1`); fresh prefill (zero init state).
141#[allow(clippy::too_many_arguments)]
142pub fn flashinfer_gdn_prefill(
143    gpu: &dyn GpuBackend,
144    qkv: DevicePtr,
145    gate_beta: DevicePtr,
146    output: DevicePtr,
147    h_state: DevicePtr,
148    scale: f32,
149    total: u32,
150    nk: u32,
151    nv: u32,
152    kd: u32,
153    vd: u32,
154    conv_dim: u32,
155    gb_stride: u32,
156    num_seqs: u32,
157    stream: u64,
158) -> Result<()> {
159    let l = lib().ok_or_else(|| anyhow!("FlashInfer GDN lib unavailable"))?;
160    let _ = gpu; // scratch (tensormaps/init/cu) is now owned+cached inside the shim
161
162    // `num_seqs == 1` is not a style preference — the managed shim writes a
163    // fixed-size `long long h[2]` (16 bytes) into its cached `m_cu` cu_seqlens
164    // buffer, which is only ever allocated once at `(num_seqs + 1) * 8`. Any
165    // num_seqs > 1 both under-fills cu_seqlens and, if the first call had
166    // num_seqs == 1, writes past the device allocation. Both call sites pass a
167    // literal 1 today; this turns "documented in the doc comment" into an error
168    // rather than silent device memory corruption.
169    ensure!(
170        num_seqs == 1,
171        "flashinfer_gdn_prefill: shim is single-sequence only (num_seqs={num_seqs})"
172    );
173
174    // Managed shim entry: caches scratch internally (no per-call alloc/free → no async
175    // use-after-free, no per-call sync). Async on `stream`, ordered with the rest of
176    // the layer like the FLA path it replaces.
177    //
178    // SAFETY: `l.prefill` is a live, process-lifetime fn pointer whose type matches
179    // the shim's C signature (see `PackedFn` / `lib()` above). The four device
180    // pointers are passed through as opaque `void*` — Rust never dereferences them,
181    // and the shim's reads are bounded by the shape arguments that accompany them:
182    // `qkv` is read with row stride `conv_dim` for `total` rows, `gate_beta` with
183    // row stride `gb_stride` for `total` rows, `output` written `[total, vd]`, and
184    // `h_state` read AND written as `nv*kd*vd` f32. Those extents are the CALLER's
185    // obligation (both call sites derive them from the same layer config that sized
186    // the buffers) and are NOT checkable from here — a wrong `conv_dim`/`gb_stride`
187    // is a device-side OOB, not something this wrapper can detect. `stream` is a
188    // valid CUstream handle owned by the backend and outlives the async launch.
189    let ret = unsafe {
190        (l.prefill)(
191            qkv.0 as *mut c_void,
192            gate_beta.0 as *mut c_void,
193            output.0 as *mut c_void,
194            h_state.0 as *mut c_void,
195            scale as c_float,
196            total as c_int,
197            nk as c_int,
198            nv as c_int,
199            kd as c_int,
200            vd as c_int,
201            conv_dim as c_int,
202            gb_stride as c_int,
203            num_seqs as c_int,
204            stream as *mut c_void,
205        )
206    };
207
208    if ret != 0 {
209        bail!("atlas_gdn_prefill_packed_managed returned {ret}");
210    }
211    Ok(())
212}