Skip to main content

cortiq_engine/
qwen_image_ops.rs

1//! Bounded weight operations used by the native Qwen Image transformer.
2//!
3//! Qwen Image keeps its large projection matrices in the CMF mapping.  A
4//! generic `QTensor::from_model` call is deliberately not used for F16/BF16
5//! matrices: that path owns a full F32 copy for dtypes without a fused
6//! quantized kernel.  `Linear::Mapped` below decodes only one bounded row at
7//! a time, while quantized tensors borrow the existing `Proj` implementation.
8
9use crate::dit::Proj;
10use crate::pool::Pool;
11use cortiq_core::{CmfModel, TensorDtype};
12use std::sync::Arc;
13
14/// Maximum number of dense rows materialized by one call to a mapped linear.
15/// The implementation currently reuses one row buffer; keeping the constant
16/// here documents the intentional bound and gives future device tiling a
17/// stable knob without changing the public API.
18pub const DENSE_ROW_TILE: usize = 32;
19
20/// A row-major `y = x · Wᵀ` projection.
21///
22/// Quantized projections stay in the existing mmap-backed `Proj`/`QTensor`
23/// path, including Q8_2f and Q4TP.  Native F32/F16/BF16 projections retain an
24/// Arc to the CMF mapping and are decoded into a single bounded scratch row.
25pub struct Linear {
26    repr: LinearRepr,
27}
28
29enum LinearRepr {
30    Mapped {
31        model: Arc<CmfModel>,
32        idx: usize,
33        dtype: TensorDtype,
34        rows: usize,
35        cols: usize,
36    },
37    Quant(Proj),
38}
39
40impl std::fmt::Debug for Linear {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        f.debug_struct("Linear")
43            .field("rows", &self.rows())
44            .field("cols", &self.cols())
45            .field("dtype", &self.dtype())
46            .finish()
47    }
48}
49
50impl Linear {
51    /// Load a rank-2 projection without materializing a whole F16/BF16
52    /// matrix.  The CMF shape is semantic `[out_features, in_features]`.
53    pub fn load(model: &Arc<CmfModel>, name: &str) -> Result<Self, String> {
54        let idx = model
55            .tensor_index(name)
56            .ok_or_else(|| format!("missing linear tensor '{name}'"))?;
57        let entry = &model.tensors[idx];
58        if entry.shape.len() != 2 {
59            return Err(format!(
60                "linear tensor '{name}' must be rank 2, got shape {:?}",
61                entry.shape
62            ));
63        }
64        let rows = entry.shape[0];
65        let cols = entry.shape[1];
66        match entry.dtype {
67            TensorDtype::F32 | TensorDtype::F16 | TensorDtype::Bf16 => Ok(Self {
68                repr: LinearRepr::Mapped {
69                    model: model.clone(),
70                    idx,
71                    dtype: entry.dtype,
72                    rows,
73                    cols,
74                },
75            }),
76            TensorDtype::Q8Row
77            | TensorDtype::Q8_2f
78            | TensorDtype::Q4Block
79            | TensorDtype::Q4Tiled
80            | TensorDtype::Q4TiledP
81            | TensorDtype::Q2TiledP
82            | TensorDtype::Vbit
83            | TensorDtype::VbitRo
84            | TensorDtype::Q1
85            | TensorDtype::Q1S
86            | TensorDtype::Q1T => Ok(Self {
87                repr: LinearRepr::Quant(Proj::from_model(model, name)?),
88            }),
89            other => Err(format!(
90                "linear tensor '{name}' has unsupported dtype {}",
91                other.name()
92            )),
93        }
94    }
95
96    /// Construct an owned F32 projection for a focused unit test. Production
97    /// model loading should use [`Self::load`] so large matrices remain
98    /// mapped or quantized.
99    #[cfg(test)]
100    pub(crate) fn from_f32_for_test(weights: Vec<f32>, rows: usize, cols: usize) -> Self {
101        assert_eq!(weights.len(), rows * cols);
102        Self {
103            repr: LinearRepr::Quant(Proj::f32(weights, cols)),
104        }
105    }
106
107    pub fn rows(&self) -> usize {
108        match &self.repr {
109            LinearRepr::Mapped { rows, .. } => *rows,
110            LinearRepr::Quant(p) => p.rows(),
111        }
112    }
113
114    pub fn cols(&self) -> usize {
115        match &self.repr {
116            LinearRepr::Mapped { cols, .. } => *cols,
117            LinearRepr::Quant(p) => p.cols(),
118        }
119    }
120
121    pub fn dtype(&self) -> Option<TensorDtype> {
122        match &self.repr {
123            LinearRepr::Mapped { dtype, .. } => Some(*dtype),
124            LinearRepr::Quant(p) => match p {
125                Proj::F32 { .. } => Some(TensorDtype::F32),
126                Proj::Q(q) => q.model_dtype(),
127            },
128        }
129    }
130
131    /// Return the mapped CMF identity for quantized device GEMMs.
132    pub(crate) fn mapped_device_gemm(&self) -> Option<(&Arc<CmfModel>, usize)> {
133        match &self.repr {
134            LinearRepr::Mapped { .. } => None,
135            LinearRepr::Quant(Proj::Q(q)) => q.mapped_device_gemm(),
136            LinearRepr::Quant(Proj::F32 { .. }) => None,
137        }
138    }
139
140    /// Return the mmap identity for a q4tp projection.  The fused QKV path
141    /// needs the directory index for all three weights; keeping this query on
142    /// the bounded `Linear` wrapper avoids materializing a `QTensor` or its
143    /// compressed payload just to discover the device handle.
144    pub(crate) fn mapped_q4tp(&self) -> Option<(&Arc<CmfModel>, usize)> {
145        match &self.repr {
146            LinearRepr::Quant(Proj::Q(q)) => q.mapped_q4tp(),
147            LinearRepr::Mapped { .. } | LinearRepr::Quant(Proj::F32 { .. }) => None,
148        }
149    }
150
151    /// Return the mmap identity for a q4tiled projection.  This is the
152    /// companion codec supported by the existing fused QKV device entry.
153    pub(crate) fn mapped_q4t(&self) -> Option<(&Arc<CmfModel>, usize)> {
154        match &self.repr {
155            LinearRepr::Quant(Proj::Q(q)) => q.mapped_q4t(),
156            LinearRepr::Mapped { .. } | LinearRepr::Quant(Proj::F32 { .. }) => None,
157        }
158    }
159
160    /// Compute `out[b, rows] = x[b, cols] · Wᵀ`.
161    ///
162    /// The output and activation buffers are caller-owned.  Mapped dense
163    /// weights are decoded in bounded row tiles and sent through the existing
164    /// GEMM path, so a 3,072×3,072 BF16 projection never becomes a 36 MiB
165    /// temporary.  Quantized projections use the existing pooled CPU/GPU
166    /// dispatch and preserve Q8_2f/Q4TP semantics.
167    pub fn forward(
168        &self,
169        x: &[f32],
170        batch: usize,
171        out: &mut [f32],
172        pool: Option<&Pool>,
173    ) -> Result<(), String> {
174        let rows = self.rows();
175        let cols = self.cols();
176        if x.len() != batch.saturating_mul(cols) {
177            return Err(format!(
178                "linear input length {} != batch {batch} × cols {cols}",
179                x.len()
180            ));
181        }
182        if out.len() != batch.saturating_mul(rows) {
183            return Err(format!(
184                "linear output length {} != batch {batch} × rows {rows}",
185                out.len()
186            ));
187        }
188        match &self.repr {
189            LinearRepr::Quant(p) => {
190                p.matmat(x, batch, out, pool);
191                Ok(())
192            }
193            LinearRepr::Mapped {
194                model,
195                idx,
196                dtype,
197                rows,
198                cols,
199            } => {
200                let entry = &model.tensors[*idx];
201                let bytes = model.entry_bytes(entry);
202                let elem_bytes = match dtype {
203                    TensorDtype::F32 => 4,
204                    TensorDtype::F16 | TensorDtype::Bf16 => 2,
205                    _ => unreachable!("mapped linear dtype is dense"),
206                };
207                let expected = rows
208                    .checked_mul(*cols)
209                    .and_then(|n| n.checked_mul(elem_bytes))
210                    .ok_or_else(|| "linear byte-size overflow".to_string())?;
211                if bytes.len() != expected {
212                    return Err(format!(
213                        "linear tensor '{}' has {} bytes, expected {}",
214                        entry.name,
215                        bytes.len(),
216                        expected
217                    ));
218                }
219                let mut tile = vec![0.0f32; DENSE_ROW_TILE * *cols];
220                let mut tile_out = vec![0.0f32; batch * DENSE_ROW_TILE];
221                for base in (0..*rows).step_by(DENSE_ROW_TILE) {
222                    let tile_rows = (*rows - base).min(DENSE_ROW_TILE);
223                    for r in 0..tile_rows {
224                        decode_dense_row(
225                            bytes,
226                            *dtype,
227                            base + r,
228                            *cols,
229                            &mut tile[r * *cols..(r + 1) * *cols],
230                        );
231                    }
232                    crate::fcd_ops::gemm_nt(
233                        x,
234                        &tile[..tile_rows * *cols],
235                        &mut tile_out[..batch * tile_rows],
236                        batch,
237                        *cols,
238                        tile_rows,
239                        pool,
240                    );
241                    for b in 0..batch {
242                        for r in 0..tile_rows {
243                            out[b * *rows + base + r] = tile_out[b * tile_rows + r];
244                        }
245                    }
246                }
247                Ok(())
248            }
249        }
250    }
251
252    /// Compute one row, useful for small control projections and fixtures.
253    pub fn forward_one(
254        &self,
255        x: &[f32],
256        out: &mut [f32],
257        pool: Option<&Pool>,
258    ) -> Result<(), String> {
259        self.forward(x, 1, out, pool)
260    }
261}
262
263/// Compute three projections of one activation panel.
264///
265/// Qwen keeps image and text Q/K/V weights separate, but each triplet reads
266/// the same normalized stream.  The existing device QKV entries can therefore
267/// remove two uploads and two waits from a large Q4TP/Q4T batch.  Every shape
268/// and dtype outside that exact mapped contract falls through to the original
269/// three `Linear::forward` calls, preserving the CPU arithmetic and all small
270/// batch behavior.
271#[allow(clippy::too_many_arguments)]
272pub(crate) fn forward_qkv(
273    q: &Linear,
274    k: &Linear,
275    v: &Linear,
276    x: &[f32],
277    batch: usize,
278    q_out: &mut [f32],
279    k_out: &mut [f32],
280    v_out: &mut [f32],
281    pool: Option<&Pool>,
282) -> Result<(), String> {
283    let cols = q.cols();
284    let qrows = q.rows();
285    let krows = k.rows();
286    let vrows = v.rows();
287    if k.cols() != cols || v.cols() != cols {
288        return Err(format!(
289            "Qwen Image QKV input widths disagree: q={cols} k={} v={}",
290            k.cols(),
291            v.cols()
292        ));
293    }
294    let x_len = batch
295        .checked_mul(cols)
296        .ok_or_else(|| "Qwen Image QKV input size overflows".to_string())?;
297    let q_len = batch
298        .checked_mul(qrows)
299        .ok_or_else(|| "Qwen Image QKV query size overflows".to_string())?;
300    let k_len = batch
301        .checked_mul(krows)
302        .ok_or_else(|| "Qwen Image QKV key size overflows".to_string())?;
303    let v_len = batch
304        .checked_mul(vrows)
305        .ok_or_else(|| "Qwen Image QKV value size overflows".to_string())?;
306    if x.len() != x_len || q_out.len() != q_len || k_out.len() != k_len || v_out.len() != v_len {
307        return Err(format!(
308            "Qwen Image QKV buffers have x={} (expected {x_len}), q={} (expected {q_len}), k={} (expected {k_len}), v={} (expected {v_len})",
309            x.len(),
310            q_out.len(),
311            k_out.len(),
312            v_out.len()
313        ));
314    }
315
316    // The shared fused entry is for the wide prefill regime in which the
317    // ordinary QTensor path also considers a device GEMM.  Keeping the same
318    // work floor prevents a tiny prompt or focused fixture from paying a
319    // device round trip that the host path intentionally avoids.
320    let wide = batch >= 32
321        && batch
322            .checked_mul(qrows)
323            .and_then(|n| n.checked_mul(cols))
324            .is_some_and(|work| work >= 128_000_000)
325        && crate::gpu::enabled_here()
326        && crate::gpu::mm_killed() == false
327        // QKV remains opt-in until the real 5120-token WGPU fixture has
328        // proved this exact compressed-payload dispatch against fallback.
329        && std::env::var("CMF_QWEN_IMAGE_FUSED_QKV").as_deref() == Ok("1");
330
331    if wide {
332        if let (Some((qm, qi)), Some((km, ki)), Some((vm, vi))) =
333            (q.mapped_q4tp(), k.mapped_q4tp(), v.mapped_q4tp())
334        {
335            if qm.uid() == km.uid() && qm.uid() == vm.uid() && krows == vrows {
336                if crate::gpu::dit_qkv(
337                    qm, qi, ki, vi, x, batch, cols, qrows, krows, q_out, k_out, v_out,
338                ) {
339                    return Ok(());
340                }
341            }
342        }
343
344        if let (Some((qm, qi)), Some((km, ki)), Some((vm, vi))) =
345            (q.mapped_q4t(), k.mapped_q4t(), v.mapped_q4t())
346        {
347            if qm.uid() == km.uid() && qm.uid() == vm.uid() {
348                let packed_len = batch
349                    .checked_mul(
350                        qrows
351                            .checked_add(krows)
352                            .and_then(|n| n.checked_add(vrows))
353                            .ok_or_else(|| "Qwen Image fused QKV size overflows".to_string())?,
354                    )
355                    .ok_or_else(|| "Qwen Image fused QKV size overflows".to_string())?;
356                let mut packed = vec![0.0f32; packed_len];
357                if crate::gpu::q4t_qkv(
358                    qm,
359                    qi,
360                    ki,
361                    vi,
362                    x,
363                    batch,
364                    cols,
365                    qrows,
366                    krows,
367                    vrows,
368                    &mut packed,
369                ) {
370                    q_out.copy_from_slice(&packed[..q_len]);
371                    k_out.copy_from_slice(&packed[q_len..q_len + k_len]);
372                    v_out.copy_from_slice(&packed[q_len + k_len..]);
373                    return Ok(());
374                }
375            }
376        }
377    }
378
379    q.forward(x, batch, q_out, pool)?;
380    k.forward(x, batch, k_out, pool)?;
381    v.forward(x, batch, v_out, pool)?;
382    Ok(())
383}
384
385fn decode_dense_row(bytes: &[u8], dtype: TensorDtype, row: usize, cols: usize, dst: &mut [f32]) {
386    debug_assert_eq!(dst.len(), cols);
387    let width = match dtype {
388        TensorDtype::F32 => 4,
389        TensorDtype::F16 | TensorDtype::Bf16 => 2,
390        _ => unreachable!("decode_dense_row only accepts dense dtypes"),
391    };
392    let start = row * cols * width;
393    let src = &bytes[start..start + cols * width];
394    for i in 0..cols {
395        let off = i * width;
396        dst[i] = match dtype {
397            TensorDtype::F32 => f32::from_le_bytes(src[off..off + 4].try_into().unwrap()),
398            TensorDtype::F16 => {
399                cortiq_core::quant::f16_to_f32(u16::from_le_bytes([src[off], src[off + 1]]))
400            }
401            TensorDtype::Bf16 => {
402                cortiq_core::quant::bf16_to_f32(u16::from_le_bytes([src[off], src[off + 1]]))
403            }
404            _ => unreachable!(),
405        };
406    }
407}
408
409/// Add a bias vector to `batch` row-major projections.
410pub(crate) fn add_bias(rows: &mut [f32], batch: usize, bias: &[f32]) -> Result<(), String> {
411    if rows.len() != batch.saturating_mul(bias.len()) {
412        return Err(format!(
413            "bias add length {} != batch {batch} × bias {}",
414            rows.len(),
415            bias.len()
416        ));
417    }
418    for row in rows.chunks_exact_mut(bias.len()) {
419        for (v, &b) in row.iter_mut().zip(bias) {
420            *v += b;
421        }
422    }
423    Ok(())
424}
425
426#[cfg(test)]
427mod tests {
428    use super::Linear;
429
430    #[test]
431    fn f32_fixture_projection_is_bounded_api_compatible() {
432        let p = Linear::from_f32_for_test(vec![1.0, 2.0, 3.0, 4.0], 2, 2);
433        let mut out = [0.0; 2];
434        p.forward_one(&[2.0, -1.0], &mut out, None).unwrap();
435        assert_eq!(out, [0.0, 2.0]);
436    }
437
438    #[test]
439    fn bias_add_rejects_wrong_batch_shape() {
440        assert!(super::add_bias(&mut [0.0; 3], 2, &[1.0, 2.0]).is_err());
441    }
442}