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}