1use anyhow::{Context, Result};
28use metrale_gpu_runtime::gpu::{DevicePtr, GpuBackend, KernelHandle};
29
30use crate::layers::ops;
31
32#[path = "qsa_select.rs"]
33mod qsa_select;
34#[path = "qsa_snapshot.rs"]
35mod qsa_snapshot;
36#[cfg(test)]
37#[path = "qsa_tests.rs"]
38mod tests;
39
40pub struct QsaSelection {
42 pub k_scratch: DevicePtr,
43 pub v_scratch: DevicePtr,
44 pub table_dev: DevicePtr,
45 pub seq_len_dev: DevicePtr,
46 pub n_sel: u32,
47 pub max_blocks: u32,
48}
49
50pub struct QsaSeqState {
51 ingested: usize,
53 pooled: usize,
55 table_len: usize,
58 raw_keys: DevicePtr,
60 block_keys: DevicePtr,
62}
63
64pub struct QsaIndexer {
65 qk_proj_w: DevicePtr,
68 q_norm_w: DevicePtr,
69 k_norm_w: DevicePtr,
70
71 n_heads: u32,
72 hd: u32,
73 ratio: u32,
74 budget: u32,
75 block_topk: u32,
76 rot: u32,
77 theta: f32,
78 eps: f32,
79 hidden: u32,
80 nkv_attn: u32,
81 hd_attn: u32,
82 max_tokens: usize,
83
84 k_pool_k: KernelHandle,
85 k_qprep_k: KernelHandle,
86 k_score_k: KernelHandle,
87 k_gather_k: KernelHandle,
88 k_qprep_rows_k: KernelHandle,
89 k_score_rows_k: KernelHandle,
90 k_score_rows_tc_k: KernelHandle,
94 k_prefill_attn_k: KernelHandle,
95
96 qk_scratch: DevicePtr,
103 q_post: DevicePtr,
104 scores_dev: DevicePtr,
105 sel_dev: DevicePtr,
106 k_scratch: DevicePtr,
107 v_scratch: DevicePtr,
108 table_dev: DevicePtr,
109 seq_len_dev: DevicePtr,
110 prefill_table_dev: DevicePtr,
113}
114
115const INGEST_SLAB: usize = 2048;
117
118impl QsaIndexer {
119 #[allow(clippy::too_many_arguments)]
120 pub fn new(
121 qk_proj_w: DevicePtr,
122 q_norm_w: DevicePtr,
123 k_norm_w: DevicePtr,
124 n_heads: usize,
125 hd: usize,
126 ratio: usize,
127 budget: usize,
128 rot: usize,
129 theta: f32,
130 eps: f32,
131 hidden: usize,
132 nkv_attn: usize,
133 hd_attn: usize,
134 gpu: &dyn GpuBackend,
135 ) -> Result<Self> {
136 anyhow::ensure!(
137 ratio > 0 && budget.is_multiple_of(ratio),
138 "QSA: budget % ratio != 0"
139 );
140 let max_tokens: usize = std::env::var("METRALE_QSA_MAX_TOKENS")
141 .ok()
142 .and_then(|v| v.parse().ok())
143 .unwrap_or(32768);
144 let block_topk = budget / ratio;
145 let qk_width = (n_heads + 1) * hd;
146 let sel_cap = budget + ratio;
147 Ok(Self {
148 qk_proj_w,
149 q_norm_w,
150 k_norm_w,
151 n_heads: n_heads as u32,
152 hd: hd as u32,
153 ratio: ratio as u32,
154 budget: budget as u32,
155 block_topk: block_topk as u32,
156 rot: rot as u32,
157 theta,
158 eps,
159 hidden: hidden as u32,
160 nkv_attn: nkv_attn as u32,
161 hd_attn: hd_attn as u32,
162 max_tokens,
163 k_pool_k: gpu.kernel("qsa_indexer", "qsa_block_pool")?,
164 k_qprep_k: gpu.kernel("qsa_indexer", "qsa_qprep")?,
165 k_score_k: gpu.kernel("qsa_indexer", "qsa_score")?,
166 k_gather_k: gpu.kernel("qsa_indexer", "qsa_gather")?,
167 k_qprep_rows_k: gpu.kernel("qsa_indexer", "qsa_qprep_rows")?,
168 k_score_rows_k: gpu.kernel("qsa_indexer", "qsa_score_rows")?,
169 k_score_rows_tc_k: crate::layers::try_kernel(gpu, "qsa_indexer", "qsa_score_rows_tc"),
170 k_prefill_attn_k: gpu.kernel("qsa_indexer", "qsa_prefill_attn")?,
171 qk_scratch: gpu.alloc(INGEST_SLAB * qk_width * 2)?,
172 q_post: gpu.alloc(n_heads * hd * 4)?,
173 scores_dev: gpu.alloc(max_tokens / ratio * 4)?,
174 sel_dev: gpu.alloc(sel_cap * 4)?,
175 k_scratch: gpu.alloc(sel_cap * nkv_attn * hd_attn * 2)?,
176 v_scratch: gpu.alloc(sel_cap * nkv_attn * hd_attn * 2)?,
177 table_dev: gpu.alloc(sel_cap.div_ceil(8) * 4)?,
178 seq_len_dev: gpu.alloc(4)?,
179 prefill_table_dev: gpu.alloc(max_tokens.div_ceil(8) * 4)?,
180 })
181 }
182
183 pub fn new_seq_state(&self, gpu: &dyn GpuBackend) -> Result<QsaSeqState> {
186 let hd = self.hd as usize;
187 let ratio = self.ratio as usize;
188 Ok(QsaSeqState {
189 ingested: 0,
190 pooled: 0,
191 table_len: 0,
192 raw_keys: gpu.alloc(self.max_tokens * hd * 2)?,
193 block_keys: gpu.alloc(self.max_tokens / ratio * hd * 2)?,
194 })
195 }
196
197 pub fn release_seq_state(&self, st: &mut QsaSeqState, gpu: &dyn GpuBackend) -> Result<()> {
208 let mut first_err: Option<anyhow::Error> = None;
209 for p in [&mut st.raw_keys, &mut st.block_keys] {
210 if p.is_null() {
211 continue;
212 }
213 if let Err(e) = gpu.free(*p) {
214 first_err.get_or_insert(e);
215 }
216 *p = DevicePtr(0);
217 }
218 match first_err {
219 Some(e) => Err(e),
220 None => Ok(()),
221 }
222 }
223
224 pub fn inert_bound(&self) -> usize {
228 (self.budget + self.ratio - 1) as usize
229 }
230
231 fn qk_width(&self) -> usize {
232 (self.n_heads as usize + 1) * self.hd as usize
233 }
234
235 pub fn prefill_ingest(
241 &self,
242 st: &mut QsaSeqState,
243 hidden: DevicePtr,
244 num_tokens: usize,
245 seq_start: usize,
246 gpu: &dyn GpuBackend,
247 stream: u64,
248 ) -> Result<()> {
249 if seq_start == 0 {
250 st.ingested = 0;
251 st.pooled = 0;
252 }
253 anyhow::ensure!(
254 seq_start == st.ingested,
255 "QSA: prefill chunk starts at {seq_start} but {} tokens are \
256 ingested — a prefix-cache skip bypassed the indexer. Serve \
257 qwen4_exp with the prefix cache disabled until QSA learns to \
258 re-ingest cached prefixes.",
259 st.ingested
260 );
261 anyhow::ensure!(
262 seq_start + num_tokens <= self.max_tokens,
263 "QSA: {} tokens exceeds METRALE_QSA_MAX_TOKENS={}",
264 seq_start + num_tokens,
265 self.max_tokens
266 );
267
268 let hd = self.hd as usize;
269 let qkw = self.qk_width();
270 let mut off = 0usize;
271 while off < num_tokens {
272 let ts = INGEST_SLAB.min(num_tokens - off);
273 ops::cublas_bf16_proj_dense(
274 hidden.offset((off) * self.hidden as usize * 2),
275 self.qk_proj_w,
276 self.qk_scratch,
277 ts as u32,
278 qkw as u32,
279 self.hidden,
280 stream,
281 )
282 .context("QSA qk projection (prefill)")?;
283 gpu.copy_d2d_2d_async(
285 self.qk_scratch.offset(self.n_heads as usize * hd * 2),
286 qkw * 2,
287 st.raw_keys.offset((seq_start + off) * hd * 2),
288 hd * 2,
289 hd * 2,
290 ts,
291 stream,
292 )?;
293 off += ts;
294 }
295 st.ingested = seq_start + num_tokens;
296 self.pool_new_blocks(st, gpu, stream)
297 }
298
299 fn pool_new_blocks(
300 &self,
301 st: &mut QsaSeqState,
302 gpu: &dyn GpuBackend,
303 stream: u64,
304 ) -> Result<()> {
305 let complete = st.ingested / self.ratio as usize;
306 if complete > st.pooled {
307 ops::qsa_block_pool(
308 gpu,
309 self.k_pool_k,
310 st.raw_keys,
311 self.k_norm_w,
312 st.block_keys,
313 st.pooled as u32,
314 (complete - st.pooled) as u32,
315 self.ratio,
316 self.hd,
317 self.rot,
318 self.theta,
319 self.eps,
320 stream,
321 )?;
322 st.pooled = complete;
323 }
324 Ok(())
325 }
326
327 #[allow(clippy::too_many_arguments)]
333 pub fn decode_select(
334 &self,
335 st: &mut QsaSeqState,
336 normed: DevicePtr,
337 pos: usize,
338 k_pool: DevicePtr,
339 v_pool: DevicePtr,
340 block_table_dev: DevicePtr,
341 block_size: u32,
342 gpu: &dyn GpuBackend,
343 stream: u64,
344 ) -> Result<Option<QsaSelection>> {
345 anyhow::ensure!(
346 pos == st.ingested,
347 "QSA: decode at pos {pos} but {} tokens ingested — the indexer \
348 cache lost sync (prefix-cache skip or a rewound sequence)",
349 st.ingested
350 );
351 anyhow::ensure!(
352 pos < self.max_tokens,
353 "QSA: pos {pos} >= METRALE_QSA_MAX_TOKENS"
354 );
355
356 let hd = self.hd as usize;
357 let qkw = self.qk_width();
358 ops::cublas_bf16_proj_dense(
359 normed,
360 self.qk_proj_w,
361 self.qk_scratch,
362 1,
363 qkw as u32,
364 self.hidden,
365 stream,
366 )
367 .context("QSA qk projection (decode)")?;
368 gpu.copy_d2d_async(
369 self.qk_scratch.offset(self.n_heads as usize * hd * 2),
370 st.raw_keys.offset(pos * hd * 2),
371 hd * 2,
372 stream,
373 )?;
374 st.ingested = pos + 1;
375 self.pool_new_blocks(st, gpu, stream)?;
376
377 let visible = pos + 1;
378 let complete = visible / self.ratio as usize;
379 if complete <= self.block_topk as usize {
380 return Ok(None); }
382
383 ops::qsa_qprep(
384 gpu,
385 self.k_qprep_k,
386 self.qk_scratch,
387 self.q_norm_w,
388 self.q_post,
389 self.n_heads,
390 self.hd,
391 self.rot,
392 pos as u32,
393 self.theta,
394 self.eps,
395 stream,
396 )?;
397 ops::qsa_score(
398 gpu,
399 self.k_score_k,
400 self.q_post,
401 st.block_keys,
402 self.scores_dev,
403 complete as u32,
404 self.n_heads,
405 self.hd,
406 stream,
407 )?;
408
409 let mut raw = vec![0u8; complete * 4];
412 gpu.copy_d2h_on_stream(self.scores_dev, &mut raw, stream)?;
413 let scores: Vec<f32> = raw
414 .chunks_exact(4)
415 .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
416 .collect();
417 let mut order: Vec<u32> = (0..complete as u32).collect();
418 order.sort_by(|&a, &b| {
420 scores[b as usize]
421 .partial_cmp(&scores[a as usize])
422 .unwrap_or(std::cmp::Ordering::Equal)
423 .then(a.cmp(&b))
424 });
425 let mut blocks: Vec<u32> = order[..self.block_topk as usize].to_vec();
426 blocks.sort_unstable();
427
428 let ratio = self.ratio as usize;
429 let mut sel: Vec<i32> = Vec::with_capacity(self.budget as usize + ratio);
430 for b in &blocks {
431 let base = *b as i32 * self.ratio as i32;
432 for r in 0..self.ratio as i32 {
433 sel.push(base + r);
434 }
435 }
436 for t in complete * ratio..visible {
437 sel.push(t as i32);
438 }
439 let n_sel = sel.len() as u32;
440
441 let sel_bytes: Vec<u8> = sel.iter().flat_map(|v| v.to_le_bytes()).collect();
442 gpu.copy_h2d_async(&sel_bytes, self.sel_dev, stream)?;
443 ops::qsa_gather(
444 gpu,
445 self.k_gather_k,
446 k_pool,
447 v_pool,
448 block_table_dev,
449 self.sel_dev,
450 self.k_scratch,
451 self.v_scratch,
452 n_sel,
453 block_size,
454 self.nkv_attn,
455 self.hd_attn,
456 stream,
457 )?;
458
459 let pages = (n_sel as usize).div_ceil(block_size as usize);
461 if st.table_len < pages {
462 let ident: Vec<u8> = (0..pages as i32).flat_map(|v| v.to_le_bytes()).collect();
463 gpu.copy_h2d_async(&ident, self.table_dev, stream)?;
464 st.table_len = pages;
465 }
466 gpu.copy_h2d_async(&(n_sel as i32).to_le_bytes(), self.seq_len_dev, stream)?;
467
468 Ok(Some(QsaSelection {
469 k_scratch: self.k_scratch,
470 v_scratch: self.v_scratch,
471 table_dev: self.table_dev,
472 seq_len_dev: self.seq_len_dev,
473 n_sel,
474 max_blocks: pages as u32,
475 }))
476 }
477}