Skip to main content

vyre_libs/nn/linear/inner/
linear_4bit.rs

1//! Fused `linear_4bit` constructor  -  unpack-on-demand 4-bit quantized linear.
2//!
3//! Instead of materializing an unpacked f32 weight buffer, this kernel loads
4//! the packed u32 weight, extracts the correct nibble inside the inner `k`
5//! loop, and accumulates directly. This eliminates the 8× memory expansion
6//! of a separate unpack dispatch.
7
8use crate::region::wrap_anonymous;
9use crate::{
10    plan_matmul_kernel, F32MatmulMode, MatmulFallbackReason, MatmulKernelCapabilities,
11    MatmulKernelPath, MatmulKernelPlan, MatrixShape,
12};
13use vyre::ir::{BufferAccess, BufferDecl, DataType, Expr, Node, Program};
14use vyre_spec::{QuantizationScale, QuantizationZeroPoint};
15
16const INT4_LINEAR_WORKGROUP_SIZE: [u32; 3] = [256, 1, 1];
17const AFFINE_GROUPED_WORKGROUP_SIZE: [u32; 3] = [256, 1, 1];
18const AFFINE_GROUPED_LANES_PER_OUTPUT: u32 = 32;
19const AFFINE_GROUPED_OUTPUTS_PER_WARP: u32 = 1;
20const AFFINE_GROUPED_WARPS_PER_WORKGROUP: u32 =
21    AFFINE_GROUPED_WORKGROUP_SIZE[0] / AFFINE_GROUPED_LANES_PER_OUTPUT;
22const AFFINE_GROUPED_OUTPUTS_PER_WORKGROUP: u32 =
23    AFFINE_GROUPED_WARPS_PER_WORKGROUP * AFFINE_GROUPED_OUTPUTS_PER_WARP;
24const AFFINE_GROUPED_OP_ID: &str = "vyre-libs::nn::linear_4bit_affine_grouped";
25
26/// Maximum absolute output drift allowed for grouped INT4 planner evidence tests.
27pub const LINEAR_4BIT_AFFINE_GROUPED_OUTPUT_DRIFT_ABS_TOLERANCE: f32 = 1.0e-4;
28
29/// Planner evidence for fused grouped INT4 linear versus dequantized matmul.
30#[derive(Debug, Clone, PartialEq)]
31pub struct QuantizedLinear4BitPlannerEvidence {
32    /// Input feature dimension.
33    pub in_dim: u32,
34    /// Output feature dimension.
35    pub out_dim: u32,
36    /// Quantization group size.
37    pub group_size: u32,
38    /// Number of quantization groups.
39    pub group_count: u32,
40    /// Packed INT4 weight bytes.
41    pub packed_weight_bytes: u64,
42    /// Bytes that a materialized f32 dequantized weight matrix would require.
43    pub dequantized_weight_bytes: u64,
44    /// Scale plus zero-point sidecar bytes.
45    pub sidecar_bytes: u64,
46    /// Bias bytes.
47    pub bias_bytes: u64,
48    /// Output bytes.
49    pub output_bytes: u64,
50    /// Dequantized weight bytes avoided by the fused path.
51    pub dequant_bytes_elided: u64,
52    /// Equivalent matmul planner M dimension.
53    pub matmul_m: u32,
54    /// Equivalent matmul planner K dimension.
55    pub matmul_k: u32,
56    /// Equivalent matmul planner N dimension.
57    pub matmul_n: u32,
58    /// Equivalent matmul planner K tile.
59    pub matmul_tile: u32,
60    /// Selected shared matmul planner path.
61    pub matmul_selected_path: &'static str,
62    /// Candidate tensor-core path from the shared matmul planner, when any.
63    pub matmul_candidate_path: Option<&'static str>,
64    /// Shared matmul planner fallback reason, when the selected path is cooperative.
65    pub matmul_fallback_reason: Option<&'static str>,
66    /// Whether the shared matmul planner selected a tensor-core path.
67    pub tensor_core_eligible: bool,
68    /// Maximum absolute output drift accepted by evidence tests.
69    pub output_drift_abs_tolerance: f32,
70}
71
72/// Typed metadata for fused grouped INT4 linear.
73///
74/// The actual packed weight buffer is still addressed as `u32` words because
75/// the kernel extracts eight nibbles per word. This spec binds that physical
76/// layout to the first-class `DataType::Quantized` contract so call sites do
77/// not pass an untyped integer buffer and lose the scale/zero-point semantics.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct QuantizedLinear4BitSpec {
80    /// Input feature dimension.
81    pub in_dim: u32,
82    /// Output feature dimension.
83    pub out_dim: u32,
84    /// First-class quantized weight metadata.
85    pub weight_type: DataType,
86}
87
88impl QuantizedLinear4BitSpec {
89    /// Build a grouped affine INT4 metadata spec.
90    #[must_use]
91    pub fn affine_grouped(in_dim: u32, out_dim: u32, group_size: u32) -> Self {
92        Self {
93            in_dim,
94            out_dim,
95            weight_type: DataType::Quantized {
96                storage: Box::new(DataType::I4),
97                scale: QuantizationScale::PerGroup { group_size },
98                zero_point: QuantizationZeroPoint::PerGroup { group_size },
99            },
100        }
101    }
102
103    fn affine_group_size(&self) -> Result<u32, String> {
104        match &self.weight_type {
105            DataType::Quantized {
106                storage,
107                scale: QuantizationScale::PerGroup { group_size },
108                zero_point:
109                    QuantizationZeroPoint::PerGroup {
110                        group_size: zp_group_size,
111                    },
112            } => {
113                if storage.as_ref() != &DataType::I4 {
114                    return Err(format!(
115                        "Fix: grouped INT4 linear requires DataType::Quantized storage I4, got {storage}."
116                    ));
117                }
118                if group_size != zp_group_size {
119                    return Err(format!(
120                        "Fix: grouped INT4 linear requires scale and zero-point group sizes to match, got scale={group_size}, zero_point={zp_group_size}."
121                    ));
122                }
123                if *group_size == 0 {
124                    return Err(
125                        "Fix: grouped INT4 linear requires quantized group_size > 0.".to_string()
126                    );
127                }
128                Ok(*group_size)
129            }
130            other => Err(format!(
131                "Fix: grouped INT4 linear requires DataType::Quantized<I4; PerGroup scale; PerGroup zero-point>, got {other}."
132            )),
133        }
134    }
135}
136
137/// Build planner evidence for [`linear_4bit_affine_grouped_typed`].
138///
139/// # Errors
140/// Returns `Err` when quantized metadata or dimensions are invalid.
141pub fn linear_4bit_affine_grouped_planner_evidence(
142    spec: &QuantizedLinear4BitSpec,
143) -> Result<QuantizedLinear4BitPlannerEvidence, String> {
144    let group_size = spec.affine_group_size()?;
145    quantized_linear_4bit_planner_evidence(spec.in_dim, spec.out_dim, group_size)
146}
147
148fn quantized_linear_4bit_planner_evidence(
149    in_dim: u32,
150    out_dim: u32,
151    group_size: u32,
152) -> Result<QuantizedLinear4BitPlannerEvidence, String> {
153    if in_dim == 0 {
154        return Err(
155            "Fix: linear_4bit_affine_grouped planner evidence requires in_dim > 0.".to_string(),
156        );
157    }
158    if out_dim == 0 {
159        return Err(
160            "Fix: linear_4bit_affine_grouped planner evidence requires out_dim > 0.".to_string(),
161        );
162    }
163    if group_size == 0 {
164        return Err(
165            "Fix: linear_4bit_affine_grouped planner evidence requires group_size > 0.".to_string(),
166        );
167    }
168    if in_dim % 8 != 0 {
169        return Err(format!(
170            "Fix: linear_4bit_affine_grouped planner evidence in_dim={in_dim} is not divisible by 8."
171        ));
172    }
173
174    let packed_words = (in_dim / 8).checked_mul(out_dim).ok_or_else(|| {
175        "Fix: linear_4bit_affine_grouped planner evidence packed weights overflow u32.".to_string()
176    })?;
177    let group_count = in_dim.div_ceil(group_size);
178    let sidecar_values = group_count.checked_mul(out_dim).ok_or_else(|| {
179        "Fix: linear_4bit_affine_grouped planner evidence sidecars overflow u32.".to_string()
180    })?;
181    let matmul_shape = MatrixShape {
182        m: out_dim,
183        k: in_dim,
184        n: 1,
185    };
186    let matmul_tile = AFFINE_GROUPED_LANES_PER_OUTPUT;
187    let matmul_plan = plan_matmul_kernel(
188        &DataType::F32,
189        matmul_shape,
190        matmul_tile,
191        1,
192        F32MatmulMode::StrictF32,
193        MatmulKernelCapabilities::current_codegen(),
194    );
195    let dequantized_weight_bytes = u64::from(in_dim)
196        .saturating_mul(u64::from(out_dim))
197        .saturating_mul(core::mem::size_of::<f32>() as u64);
198    let packed_weight_bytes = u64::from(packed_words) * core::mem::size_of::<u32>() as u64;
199    let sidecar_bytes = u64::from(sidecar_values)
200        .saturating_mul((core::mem::size_of::<f32>() + core::mem::size_of::<u32>()) as u64);
201    let output_bytes = u64::from(out_dim) * core::mem::size_of::<f32>() as u64;
202
203    Ok(QuantizedLinear4BitPlannerEvidence {
204        in_dim,
205        out_dim,
206        group_size,
207        group_count,
208        packed_weight_bytes,
209        dequantized_weight_bytes,
210        sidecar_bytes,
211        bias_bytes: output_bytes,
212        output_bytes,
213        dequant_bytes_elided: dequantized_weight_bytes,
214        matmul_m: matmul_shape.m,
215        matmul_k: matmul_shape.k,
216        matmul_n: matmul_shape.n,
217        matmul_tile,
218        matmul_selected_path: matmul_path_label(matmul_plan.selected_path),
219        matmul_candidate_path: matmul_plan.candidate_path.map(matmul_path_label),
220        matmul_fallback_reason: matmul_fallback_label(&matmul_plan),
221        tensor_core_eligible: matmul_plan.selected_path != MatmulKernelPath::Cooperative,
222        output_drift_abs_tolerance: LINEAR_4BIT_AFFINE_GROUPED_OUTPUT_DRIFT_ABS_TOLERANCE,
223    })
224}
225
226fn matmul_path_label(path: MatmulKernelPath) -> &'static str {
227    match path {
228        MatmulKernelPath::Cooperative => "cooperative",
229        MatmulKernelPath::TensorCoreF16M16N8K16 => "tensor_core_f16_m16n8k16",
230        MatmulKernelPath::TensorCoreBf16M16N8K16 => "tensor_core_bf16_m16n8k16",
231        MatmulKernelPath::TensorCoreTf32M16N8K4 => "tensor_core_tf32_m16n8k4",
232    }
233}
234
235fn matmul_fallback_label(plan: &MatmulKernelPlan) -> Option<&'static str> {
236    match plan.fallback_reason {
237        Some(MatmulFallbackReason::StrictF32Requested) => Some("strict_f32_requested"),
238        Some(MatmulFallbackReason::UnsupportedDtype) => Some("unsupported_dtype"),
239        Some(MatmulFallbackReason::TileSizeMismatch { .. }) => Some("tile_size_mismatch"),
240        Some(MatmulFallbackReason::RaggedTileUnsupported) => Some("ragged_tile_unsupported"),
241        Some(MatmulFallbackReason::SplitKUnsupported) => Some("split_k_unsupported"),
242        Some(MatmulFallbackReason::TensorCoreDtypeUnsupported) => {
243            Some("tensor_core_dtype_unsupported")
244        }
245        None => None,
246    }
247}
248
249/// Build a Program that computes `out[i] = sum_k x[k] * unpack(w_packed[k,i]) + b[i]`
250/// where `w_packed` stores 8 4-bit weights per u32.
251///
252/// `in_dim` must be divisible by 8 (each output column consumes `in_dim/8` u32s).
253///
254/// # Errors
255/// Returns `Err` when `in_dim == 0` or `in_dim % 8 != 0`.
256pub fn linear_4bit(
257    x: &str,
258    w_packed: &str,
259    b: &str,
260    out: &str,
261    in_dim: u32,
262    out_dim: u32,
263) -> Result<Program, String> {
264    if in_dim == 0 {
265        return Err("Fix: linear_4bit in_dim=0 is invalid: empty reduction".to_string());
266    }
267    if out_dim == 0 {
268        return Err("Fix: linear_4bit out_dim=0 is invalid: empty output".to_string());
269    }
270    if in_dim % 8 != 0 {
271        return Err(format!(
272            "Fix: linear_4bit in_dim={in_dim} is not divisible by 8; pad weights to a multiple of 8."
273        ));
274    }
275    let u32s_per_col = in_dim / 8;
276    let total_u32s = u32s_per_col.checked_mul(out_dim).ok_or_else(|| {
277        "Fix: linear_4bit in_dim/8 * out_dim overflows u32; reduce dimensions.".to_string()
278    })?;
279
280    let i = Expr::var("i");
281    let k = Expr::var("k");
282
283    // packed_index = k / 8 * out_dim + i
284    let packed_idx = Expr::add(
285        Expr::mul(Expr::div(k.clone(), Expr::u32(8)), Expr::u32(out_dim)),
286        i.clone(),
287    );
288    // nibble_shift = (k % 8) * 4
289    let shift = Expr::mul(Expr::rem(k.clone(), Expr::u32(8)), Expr::u32(4));
290    // unpacked_nibble = (w_packed[packed_idx] >> shift) & 0xF
291    let nibble = Expr::bitand(
292        Expr::shr(Expr::load(w_packed, packed_idx), shift),
293        Expr::u32(0xF),
294    );
295    // cast to f32 for accumulation
296    let weight_f32 = Expr::cast(DataType::F32, nibble);
297
298    let body = vec![
299        Node::let_bind("i", Expr::InvocationId { axis: 0 }),
300        Node::if_then(
301            Expr::lt(i.clone(), Expr::u32(out_dim)),
302            vec![
303                Node::let_bind("acc", Expr::load(b, i.clone())),
304                Node::loop_for(
305                    "k",
306                    Expr::u32(0),
307                    Expr::u32(in_dim),
308                    vec![Node::assign(
309                        "acc",
310                        Expr::add(
311                            Expr::var("acc"),
312                            Expr::mul(Expr::load(x, k.clone()), weight_f32.clone()),
313                        ),
314                    )],
315                ),
316                Node::Store {
317                    buffer: out.into(),
318                    index: i,
319                    value: Expr::var("acc"),
320                },
321            ],
322        ),
323    ];
324
325    Ok(Program::wrapped(
326        vec![
327            BufferDecl::storage(x, 0, BufferAccess::ReadOnly, DataType::F32).with_count(in_dim),
328            BufferDecl::storage(w_packed, 1, BufferAccess::ReadOnly, DataType::U32)
329                .with_count(total_u32s),
330            BufferDecl::storage(b, 2, BufferAccess::ReadOnly, DataType::F32).with_count(out_dim),
331            BufferDecl::output(out, 3, DataType::F32).with_count(out_dim),
332        ],
333        INT4_LINEAR_WORKGROUP_SIZE,
334        vec![wrap_anonymous("vyre-libs::nn::linear_4bit", body)],
335    ))
336}
337
338/// Build a fused affine INT4 linear Program:
339///
340/// `out[i] = b[i] + sum_k x[k] * ((unpack4(w_packed[k,i]) - zero_point[group,i]) * scale[group,i])`
341///
342/// This keeps weights packed, applies per-group quantization metadata inside
343/// the dot-product loop, and avoids a separate dequantize materialization
344/// dispatch. `w_packed` stores 8 4-bit weights per u32 using the same
345/// column-interleaved layout as [`linear_4bit`]. `scale` is f32, `zero_point`
346/// is u32 with values expected in `0..=15`, and both sidecar buffers are
347/// indexed as `group * out_dim + i`.
348///
349/// For bounded group counts the emitted IR hoists and lane-predicates each
350/// scale/zero-point load, broadcasts the values once per `(group, output)`,
351/// and expresses dequantization plus accumulation as fused multiply-adds.
352/// This removes per-MAC group division, repeated sidecar loads, and redundant
353/// affine arithmetic from the inference path.
354///
355/// # Errors
356/// Returns `Err` when dimensions are empty, `group_size == 0`,
357/// `in_dim % 8 != 0`, or derived sidecar/storage counts overflow `u32`.
358pub fn linear_4bit_affine_grouped(
359    x: &str,
360    w_packed: &str,
361    scale: &str,
362    zero_point: &str,
363    b: &str,
364    out: &str,
365    in_dim: u32,
366    out_dim: u32,
367    group_size: u32,
368) -> Result<Program, String> {
369    if in_dim == 0 {
370        return Err(
371            "Fix: linear_4bit_affine_grouped in_dim=0 is invalid: empty reduction".to_string(),
372        );
373    }
374    if out_dim == 0 {
375        return Err(
376            "Fix: linear_4bit_affine_grouped out_dim=0 is invalid: empty output".to_string(),
377        );
378    }
379    if group_size == 0 {
380        return Err(
381            "Fix: linear_4bit_affine_grouped group_size=0 is invalid: group size must be > 0"
382                .to_string(),
383        );
384    }
385    if in_dim % 8 != 0 {
386        return Err(format!(
387            "Fix: linear_4bit_affine_grouped in_dim={in_dim} is not divisible by 8; pad weights to a multiple of 8."
388        ));
389    }
390    let u32s_per_col = in_dim / 8;
391    let total_u32s = u32s_per_col.checked_mul(out_dim).ok_or_else(|| {
392        "Fix: linear_4bit_affine_grouped in_dim/8 * out_dim overflows u32; reduce dimensions."
393            .to_string()
394    })?;
395    let group_count = in_dim.div_ceil(group_size);
396    let sidecar_count = group_count.checked_mul(out_dim).ok_or_else(|| {
397        "Fix: linear_4bit_affine_grouped group_count*out_dim overflows u32; reduce dimensions."
398            .to_string()
399    })?;
400
401    let tile = AFFINE_GROUPED_LANES_PER_OUTPUT;
402    let chunks = in_dim.div_ceil(tile);
403    let out_idx = Expr::var("out_idx");
404    let local = Expr::var("local");
405    let lane = Expr::var("lane");
406    let k = Expr::var("k");
407    let lane_in_word = Expr::var("lane_in_word");
408    let word_leader_lane = Expr::var("word_leader_lane");
409    let word_leader_k = Expr::var("word_leader_k");
410    let packed_idx = Expr::add(
411        Expr::mul(
412            Expr::div(word_leader_k.clone(), Expr::u32(8)),
413            Expr::u32(out_dim),
414        ),
415        out_idx.clone(),
416    );
417    let shift = Expr::mul(lane_in_word.clone(), Expr::u32(4));
418    let nibble = Expr::bitand(Expr::shr(Expr::var("packed_word"), shift), Expr::u32(0xF));
419    let group = Expr::div(k.clone(), Expr::u32(group_size));
420    let chunk_sidecar_idx = Expr::add(Expr::mul(group, Expr::u32(out_dim)), out_idx.clone());
421    let weight_f32 = Expr::fma(
422        Expr::cast(DataType::F32, nibble),
423        Expr::var("group_scale"),
424        Expr::var("group_zero_offset"),
425    );
426
427    let mut per_output = vec![Node::let_bind("local_acc", Expr::f32(0.0))];
428    if group_size > tile && group_size % tile == 0 {
429        let group_chunks = group_size.div_ceil(tile);
430        per_output.push(Node::loop_for(
431            "group_idx",
432            Expr::u32(0),
433            Expr::u32(group_count),
434            vec![
435                Node::let_bind(
436                    "group_base",
437                    Expr::mul(Expr::var("group_idx"), Expr::u32(group_size)),
438                ),
439                Node::let_bind(
440                    "sidecar_idx",
441                    Expr::add(
442                        Expr::mul(Expr::var("group_idx"), Expr::u32(out_dim)),
443                        out_idx.clone(),
444                    ),
445                ),
446                Node::let_bind(
447                    "scale_lane",
448                    Expr::select(
449                        Expr::eq(lane.clone(), Expr::u32(0)),
450                        Expr::load(scale, Expr::var("sidecar_idx")),
451                        Expr::f32(0.0),
452                    ),
453                ),
454                Node::let_bind(
455                    "zero_point_lane",
456                    Expr::select(
457                        Expr::eq(lane.clone(), Expr::u32(0)),
458                        Expr::load(zero_point, Expr::var("sidecar_idx")),
459                        Expr::u32(0),
460                    ),
461                ),
462                Node::let_bind(
463                    "group_scale",
464                    Expr::subgroup_shuffle(Expr::var("scale_lane"), Expr::u32(0)),
465                ),
466                Node::let_bind(
467                    "group_zero_point",
468                    Expr::subgroup_shuffle(Expr::var("zero_point_lane"), Expr::u32(0)),
469                ),
470                Node::let_bind(
471                    "group_zero_offset",
472                    Expr::mul(
473                        Expr::f32(-1.0),
474                        Expr::mul(
475                            Expr::cast(DataType::F32, Expr::var("group_zero_point")),
476                            Expr::var("group_scale"),
477                        ),
478                    ),
479                ),
480                Node::loop_for(
481                    "group_chunk",
482                    Expr::u32(0),
483                    Expr::u32(group_chunks),
484                    vec![
485                        Node::let_bind(
486                            "k",
487                            Expr::add(
488                                Expr::var("group_base"),
489                                Expr::add(
490                                    Expr::mul(Expr::var("group_chunk"), Expr::u32(tile)),
491                                    lane.clone(),
492                                ),
493                            ),
494                        ),
495                        Node::let_bind("lane_in_word", Expr::bitand(lane.clone(), Expr::u32(7))),
496                        Node::let_bind(
497                            "word_leader_lane",
498                            Expr::bitand(lane.clone(), Expr::u32(0xffff_fff8)),
499                        ),
500                        Node::let_bind(
501                            "word_leader_k",
502                            Expr::add(
503                                Expr::var("group_base"),
504                                Expr::add(
505                                    Expr::mul(Expr::var("group_chunk"), Expr::u32(tile)),
506                                    word_leader_lane.clone(),
507                                ),
508                            ),
509                        ),
510                        Node::let_bind(
511                            "packed_word_lane",
512                            Expr::select(
513                                Expr::and(
514                                    Expr::eq(lane_in_word.clone(), Expr::u32(0)),
515                                    Expr::lt(word_leader_k.clone(), Expr::u32(in_dim)),
516                                ),
517                                Expr::load(w_packed, packed_idx.clone()),
518                                Expr::u32(0),
519                            ),
520                        ),
521                        Node::let_bind(
522                            "packed_word",
523                            Expr::subgroup_shuffle(Expr::var("packed_word_lane"), word_leader_lane),
524                        ),
525                        Node::if_then(
526                            Expr::lt(k.clone(), Expr::u32(in_dim)),
527                            vec![Node::assign(
528                                "local_acc",
529                                Expr::fma(
530                                    Expr::load(x, k.clone()),
531                                    weight_f32.clone(),
532                                    Expr::var("local_acc"),
533                                ),
534                            )],
535                        ),
536                    ],
537                ),
538            ],
539        ));
540    } else {
541        per_output.push(Node::loop_for(
542            "chunk",
543            Expr::u32(0),
544            Expr::u32(chunks),
545            vec![
546                Node::let_bind(
547                    "k",
548                    Expr::add(Expr::mul(Expr::var("chunk"), Expr::u32(tile)), lane.clone()),
549                ),
550                Node::let_bind("lane_in_word", Expr::bitand(lane.clone(), Expr::u32(7))),
551                Node::let_bind(
552                    "word_leader_lane",
553                    Expr::bitand(lane.clone(), Expr::u32(0xffff_fff8)),
554                ),
555                Node::let_bind(
556                    "word_leader_k",
557                    Expr::add(
558                        Expr::mul(Expr::var("chunk"), Expr::u32(tile)),
559                        word_leader_lane.clone(),
560                    ),
561                ),
562                Node::let_bind(
563                    "packed_word_lane",
564                    Expr::select(
565                        Expr::and(
566                            Expr::eq(lane_in_word.clone(), Expr::u32(0)),
567                            Expr::lt(word_leader_k.clone(), Expr::u32(in_dim)),
568                        ),
569                        Expr::load(w_packed, packed_idx),
570                        Expr::u32(0),
571                    ),
572                ),
573                Node::let_bind(
574                    "packed_word",
575                    Expr::subgroup_shuffle(Expr::var("packed_word_lane"), word_leader_lane),
576                ),
577                Node::let_bind("sidecar_idx", chunk_sidecar_idx),
578                Node::let_bind("group_scale", Expr::load(scale, Expr::var("sidecar_idx"))),
579                Node::let_bind(
580                    "group_zero_point",
581                    Expr::load(zero_point, Expr::var("sidecar_idx")),
582                ),
583                Node::let_bind(
584                    "group_zero_offset",
585                    Expr::mul(
586                        Expr::f32(-1.0),
587                        Expr::mul(
588                            Expr::cast(DataType::F32, Expr::var("group_zero_point")),
589                            Expr::var("group_scale"),
590                        ),
591                    ),
592                ),
593                Node::if_then(
594                    Expr::lt(k.clone(), Expr::u32(in_dim)),
595                    vec![Node::assign(
596                        "local_acc",
597                        Expr::fma(Expr::load(x, k.clone()), weight_f32, Expr::var("local_acc")),
598                    )],
599                ),
600            ],
601        ));
602    }
603    per_output.push(Node::let_bind(
604        "warp_sum",
605        Expr::subgroup_add(Expr::var("local_acc")),
606    ));
607    per_output.push(Node::if_then(
608        Expr::eq(lane.clone(), Expr::u32(0)),
609        vec![Node::Store {
610            buffer: out.into(),
611            index: out_idx.clone(),
612            value: Expr::add(Expr::load(b, out_idx.clone()), Expr::var("warp_sum")),
613        }],
614    ));
615
616    let body = vec![
617        Node::let_bind("local", Expr::LocalId { axis: 0 }),
618        Node::let_bind(
619            "warp",
620            Expr::div(local.clone(), Expr::u32(AFFINE_GROUPED_LANES_PER_OUTPUT)),
621        ),
622        Node::let_bind(
623            "lane",
624            Expr::rem(local.clone(), Expr::u32(AFFINE_GROUPED_LANES_PER_OUTPUT)),
625        ),
626        Node::loop_for(
627            "warp_output",
628            Expr::u32(0),
629            Expr::u32(AFFINE_GROUPED_OUTPUTS_PER_WARP),
630            vec![
631                Node::let_bind(
632                    "out_idx",
633                    Expr::add(
634                        Expr::add(
635                            Expr::mul(
636                                Expr::WorkgroupId { axis: 0 },
637                                Expr::u32(AFFINE_GROUPED_OUTPUTS_PER_WORKGROUP),
638                            ),
639                            Expr::mul(
640                                Expr::var("warp_output"),
641                                Expr::u32(AFFINE_GROUPED_WARPS_PER_WORKGROUP),
642                            ),
643                        ),
644                        Expr::var("warp"),
645                    ),
646                ),
647                Node::if_then(Expr::lt(out_idx.clone(), Expr::u32(out_dim)), per_output),
648            ],
649        ),
650    ];
651    let output_workgroups = out_dim.div_ceil(AFFINE_GROUPED_OUTPUTS_PER_WORKGROUP);
652    let padded_output_count = output_workgroups
653        .checked_mul(AFFINE_GROUPED_WORKGROUP_SIZE[0])
654        .ok_or_else(|| {
655            "Fix: linear_4bit_affine_grouped output workgroups overflow u32; reduce dimensions."
656                .to_string()
657        })?;
658    let output_byte_len = (out_dim as usize)
659        .checked_mul(core::mem::size_of::<f32>())
660        .ok_or_else(|| {
661            "Fix: linear_4bit_affine_grouped output byte length overflows usize; reduce dimensions."
662                .to_string()
663        })?;
664
665    Ok(Program::wrapped(
666        vec![
667            BufferDecl::storage(x, 0, BufferAccess::ReadOnly, DataType::F32).with_count(in_dim),
668            BufferDecl::storage(w_packed, 1, BufferAccess::ReadOnly, DataType::U32)
669                .with_count(total_u32s),
670            BufferDecl::storage(scale, 2, BufferAccess::ReadOnly, DataType::F32)
671                .with_count(sidecar_count),
672            BufferDecl::storage(zero_point, 3, BufferAccess::ReadOnly, DataType::U32)
673                .with_count(sidecar_count),
674            BufferDecl::storage(b, 4, BufferAccess::ReadOnly, DataType::F32).with_count(out_dim),
675            BufferDecl::output(out, 5, DataType::F32)
676                .with_count(padded_output_count)
677                .with_output_byte_range(0..output_byte_len),
678        ],
679        AFFINE_GROUPED_WORKGROUP_SIZE,
680        vec![wrap_anonymous(AFFINE_GROUPED_OP_ID, body)],
681    ))
682}
683
684/// Build [`linear_4bit_affine_grouped`] from first-class quantized metadata.
685///
686/// # Errors
687/// Returns `Err` when the spec is not `Quantized<I4; PerGroup; PerGroup>`,
688/// when scale/zero-point group sizes differ, or when dimensions are invalid.
689pub fn linear_4bit_affine_grouped_typed(
690    spec: &QuantizedLinear4BitSpec,
691    x: &str,
692    w_packed: &str,
693    scale: &str,
694    zero_point: &str,
695    b: &str,
696    out: &str,
697) -> Result<Program, String> {
698    let group_size = spec.affine_group_size()?;
699    linear_4bit_affine_grouped(
700        x,
701        w_packed,
702        scale,
703        zero_point,
704        b,
705        out,
706        spec.in_dim,
707        spec.out_dim,
708        group_size,
709    )
710}
711
712#[cfg(test)]
713mod tests {
714    use super::*;
715    use crate::test_support::byte_pack::f32_bytes;
716    use crate::test_support::byte_pack::u32_bytes;
717    use vyre_reference::value::Value;
718
719    fn expr_contains_subgroup_shuffle(expr: &Expr) -> bool {
720        match expr {
721            Expr::Load { index, .. }
722            | Expr::Cast { value: index, .. }
723            | Expr::SubgroupReduce { value: index, .. }
724            | Expr::SubgroupBallot { cond: index }
725            | Expr::UnOp { operand: index, .. } => expr_contains_subgroup_shuffle(index),
726            Expr::BinOp { left, right, .. }
727            | Expr::SubgroupShuffle {
728                value: left,
729                lane: right,
730            } => {
731                matches!(expr, Expr::SubgroupShuffle { .. })
732                    || expr_contains_subgroup_shuffle(left)
733                    || expr_contains_subgroup_shuffle(right)
734            }
735            Expr::Select {
736                cond,
737                true_val,
738                false_val,
739            } => {
740                expr_contains_subgroup_shuffle(cond)
741                    || expr_contains_subgroup_shuffle(true_val)
742                    || expr_contains_subgroup_shuffle(false_val)
743            }
744            Expr::Fma { a, b, c } => {
745                expr_contains_subgroup_shuffle(a)
746                    || expr_contains_subgroup_shuffle(b)
747                    || expr_contains_subgroup_shuffle(c)
748            }
749            Expr::Atomic {
750                index,
751                expected,
752                value,
753                ..
754            } => {
755                expr_contains_subgroup_shuffle(index)
756                    || expected
757                        .as_deref()
758                        .is_some_and(expr_contains_subgroup_shuffle)
759                    || expr_contains_subgroup_shuffle(value)
760            }
761            Expr::Call { args, .. } => args.iter().any(expr_contains_subgroup_shuffle),
762            Expr::LitU32(_)
763            | Expr::LitI32(_)
764            | Expr::LitF32(_)
765            | Expr::LitBool(_)
766            | Expr::Var(_)
767            | Expr::BufLen { .. }
768            | Expr::InvocationId { .. }
769            | Expr::WorkgroupId { .. }
770            | Expr::LocalId { .. }
771            | Expr::SubgroupLocalId
772            | Expr::SubgroupSize
773            | Expr::Opaque(_) => false,
774            _ => false,
775        }
776    }
777
778    fn nodes_contain_subgroup_shuffle(nodes: &[Node]) -> bool {
779        nodes.iter().any(|node| match node {
780            Node::Let { value, .. } | Node::Assign { value, .. } => {
781                expr_contains_subgroup_shuffle(value)
782            }
783            Node::Store { index, value, .. } => {
784                expr_contains_subgroup_shuffle(index) || expr_contains_subgroup_shuffle(value)
785            }
786            Node::If {
787                cond,
788                then,
789                otherwise,
790            } => {
791                expr_contains_subgroup_shuffle(cond)
792                    || nodes_contain_subgroup_shuffle(then)
793                    || nodes_contain_subgroup_shuffle(otherwise)
794            }
795            Node::Loop { from, to, body, .. } => {
796                expr_contains_subgroup_shuffle(from)
797                    || expr_contains_subgroup_shuffle(to)
798                    || nodes_contain_subgroup_shuffle(body)
799            }
800            Node::AsyncLoad { offset, size, .. } | Node::AsyncStore { offset, size, .. } => {
801                expr_contains_subgroup_shuffle(offset) || expr_contains_subgroup_shuffle(size)
802            }
803            Node::Trap { address, .. } => expr_contains_subgroup_shuffle(address),
804            Node::Block(body) => nodes_contain_subgroup_shuffle(body),
805            Node::Region { body, .. } => nodes_contain_subgroup_shuffle(body),
806            Node::IndirectDispatch { .. }
807            | Node::AsyncWait { .. }
808            | Node::AllReduce { .. }
809            | Node::AllGather { .. }
810            | Node::ReduceScatter { .. }
811            | Node::Broadcast { .. }
812            | Node::Return
813            | Node::Barrier { .. }
814            | Node::Resume { .. }
815            | Node::Opaque(_) => false,
816            _ => false,
817        })
818    }
819
820    fn collect_loop_vars(nodes: &[Node], vars: &mut Vec<String>) {
821        for node in nodes {
822            match node {
823                Node::If {
824                    then, otherwise, ..
825                } => {
826                    collect_loop_vars(then, vars);
827                    collect_loop_vars(otherwise, vars);
828                }
829                Node::Loop { var, body, .. } => {
830                    vars.push(var.to_string());
831                    collect_loop_vars(body, vars);
832                }
833                Node::Block(body) => collect_loop_vars(body, vars),
834                Node::Region { body, .. } => collect_loop_vars(body, vars),
835                _ => {}
836            }
837        }
838    }
839
840    fn affine_cpu_reference(
841        x: &[f32],
842        packed: &[u32],
843        scale: &[f32],
844        zero_point: &[u32],
845        bias: &[f32],
846        in_dim: u32,
847        out_dim: u32,
848        group_size: u32,
849    ) -> Vec<f32> {
850        (0..out_dim as usize)
851            .map(|out| {
852                let mut acc = bias[out];
853                for k in 0..in_dim as usize {
854                    let word = packed[(k / 8) * out_dim as usize + out];
855                    let nibble = ((word >> ((k % 8) * 4)) & 0xF) as f32;
856                    let sidecar_idx = (k / group_size as usize) * out_dim as usize + out;
857                    acc += x[k] * (nibble - zero_point[sidecar_idx] as f32) * scale[sidecar_idx];
858                }
859                acc
860            })
861            .collect()
862    }
863
864    #[test]
865    fn linear_4bit_matches_unpack_then_linear() {
866        // in_dim = 8, out_dim = 2
867        // x = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]
868        let x = f32_bytes(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]);
869        // Weights: 2 output columns, each with 8 nibbles (2 u32s)
870        // Column 0 nibbles: [1, 2, 3, 4, 5, 6, 7, 8] → packed as:
871        //   u32[0] = 0x_8_7_6_5_4_3_2_1 (little-endian byte order, but nibble order within u32)
872        //   Actually in our unpack: nibble for k=0 is bits[3:0], k=1 is bits[7:4], etc.
873        //   So u32[0] = (8<<28)|(7<<24)|(6<<20)|(5<<16)|(4<<12)|(3<<8)|(2<<4)|1
874        let col0 = 0x8765_4321u32;
875        // Column 1 nibbles: [0, 0, 0, 0, 0, 0, 0, 0]
876        let col1 = 0x0000_0000u32;
877        let w = u32_bytes(&[col0, col1]);
878        // bias = [0.0, 0.0]
879        let b = f32_bytes(&[0.0, 0.0]);
880        let out_size = 2usize * 4;
881
882        let program = linear_4bit("x", "w", "b", "out", 8, 2).unwrap();
883        let outputs = vyre_reference::reference_eval(
884            &program,
885            &[
886                Value::from(x),
887                Value::from(w),
888                Value::from(b),
889                Value::from(vec![0u8; out_size]),
890            ],
891        )
892        .expect("Fix: reference eval must succeed");
893
894        let out_vals: Vec<f32> =
895            vyre_primitives::wire::decode_f32_le_bytes_all(&outputs[0].to_bytes());
896
897        // Column 0: sum_k x[k] * nibble[k] = 1*1 + 2*2 + 3*3 + 4*4 + 5*5 + 6*6 + 7*7 + 8*8 = 204
898        assert!(
899            (out_vals[0] - 204.0).abs() < 1e-4,
900            "expected 204.0, got {}",
901            out_vals[0]
902        );
903        // Column 1: all zero nibbles → 0
904        assert!(
905            (out_vals[1] - 0.0).abs() < 1e-4,
906            "expected 0.0, got {}",
907            out_vals[1]
908        );
909    }
910
911    #[test]
912    fn linear_4bit_rejects_indivisible_in_dim() {
913        let err = linear_4bit("x", "w", "b", "out", 7, 4).unwrap_err();
914        assert!(
915            err.contains("divisible by 8"),
916            "error must mention divisibility: {err}"
917        );
918    }
919
920    #[test]
921    fn linear_4bit_affine_grouped_applies_scale_and_zero_point_in_loop() {
922        let x = f32_bytes(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]);
923        let w = u32_bytes(&[0x8765_4321u32, 0x0000_0000u32]);
924        let scale = f32_bytes(&[0.5, 1.0, 2.0, 1.0]);
925        let zero_point = u32_bytes(&[1, 0, 4, 0]);
926        let b = f32_bytes(&[0.0, 3.0]);
927
928        let program = linear_4bit_affine_grouped("x", "w", "scale", "zp", "b", "out", 8, 2, 4)
929            .expect("Fix: affine grouped int4 linear fixture must build");
930        assert_eq!(
931            program.workgroup_size(),
932            AFFINE_GROUPED_WORKGROUP_SIZE,
933            "Fix: grouped INT4 linear must keep the CUDA-measured cooperative release launch shape."
934        );
935        let outputs = vyre_reference::reference_eval(
936            &program,
937            &[
938                Value::from(x),
939                Value::from(w),
940                Value::from(scale),
941                Value::from(zero_point),
942                Value::from(b),
943                Value::from(vec![0u8; 8]),
944            ],
945        )
946        .expect("Fix: affine grouped int4 linear must execute");
947
948        let out_vals = vyre_primitives::wire::decode_f32_le_bytes_all(&outputs[0].to_bytes());
949
950        assert!(
951            (out_vals[0] - 150.0).abs() < 1e-4,
952            "expected fused affine dequantized dot product 150.0, got {}",
953            out_vals[0]
954        );
955        let evidence = linear_4bit_affine_grouped_planner_evidence(
956            &QuantizedLinear4BitSpec::affine_grouped(8, 2, 4),
957        )
958        .expect("Fix: planner evidence fixture must build");
959        assert!(
960            (out_vals[0] - 150.0).abs() <= evidence.output_drift_abs_tolerance,
961            "Fix: runtime output drift must stay within planner evidence tolerance."
962        );
963        assert!(
964            (out_vals[1] - 3.0).abs() < 1e-4,
965            "expected bias-only second output 3.0, got {}",
966            out_vals[1]
967        );
968    }
969
970    #[test]
971    fn linear_4bit_affine_grouped_broadcasts_packed_weight_words() {
972        let program =
973            linear_4bit_affine_grouped("x", "w", "scale", "zp", "b", "out", 256, 4096, 64)
974                .expect("Fix: grouped INT4 affine release fixture must build");
975
976        assert!(
977            nodes_contain_subgroup_shuffle(program.entry()),
978            "Fix: grouped INT4 release kernel must broadcast each packed u32 weight word across its 8 nibble lanes instead of reloading it per MAC."
979        );
980    }
981
982    #[test]
983    fn linear_4bit_affine_grouped_hoists_sidecars_for_aligned_release_groups() {
984        let aligned =
985            linear_4bit_affine_grouped("x", "w", "scale", "zp", "b", "out", 256, 4096, 64)
986                .expect("Fix: aligned grouped INT4 release fixture must build");
987        let mut aligned_loops = Vec::new();
988        collect_loop_vars(aligned.entry(), &mut aligned_loops);
989        assert!(
990            aligned_loops.iter().any(|var| var == "group_idx")
991                && aligned_loops.iter().any(|var| var == "group_chunk"),
992            "Fix: release-aligned grouped INT4 must load and broadcast sidecars once per quantization group, then scan that group's chunks: {aligned_loops:?}"
993        );
994        assert!(
995            !aligned_loops.iter().any(|var| var == "chunk"),
996            "Fix: release-aligned grouped INT4 must not use the per-chunk sidecar broadcast path: {aligned_loops:?}"
997        );
998
999        let single_tile =
1000            linear_4bit_affine_grouped("x", "w", "scale", "zp", "b", "out", 32, 8, 32)
1001                .expect("Fix: single-tile grouped INT4 fixture must build");
1002        let mut single_tile_loops = Vec::new();
1003        collect_loop_vars(single_tile.entry(), &mut single_tile_loops);
1004        assert!(
1005            single_tile_loops.iter().any(|var| var == "chunk")
1006                && !single_tile_loops.iter().any(|var| var == "group_idx"),
1007            "Fix: single-tile and non-tile-aligned quantization groups must retain chunk-indexed sidecar selection for correctness: {single_tile_loops:?}"
1008        );
1009    }
1010
1011    #[test]
1012    fn linear_4bit_affine_grouped_rejects_zero_group_size() {
1013        let err =
1014            linear_4bit_affine_grouped("x", "w", "scale", "zp", "b", "out", 8, 4, 0).unwrap_err();
1015        assert!(
1016            err.contains("group_size=0"),
1017            "error must identify invalid group size: {err}"
1018        );
1019    }
1020
1021    #[test]
1022    fn typed_affine_grouped_builder_uses_quantized_metadata() {
1023        let spec = QuantizedLinear4BitSpec::affine_grouped(32, 7, 8);
1024        let program = linear_4bit_affine_grouped_typed(&spec, "x", "w", "scale", "zp", "b", "out")
1025            .expect("Fix: valid typed grouped INT4 spec must build");
1026
1027        assert_eq!(program.buffers()[1].name(), "w");
1028        assert_eq!(program.buffers()[1].element(), DataType::U32);
1029        assert_eq!(program.buffers()[1].count(), 28);
1030        assert!(matches!(
1031            spec.weight_type,
1032            DataType::Quantized {
1033                scale: QuantizationScale::PerGroup { group_size: 8 },
1034                zero_point: QuantizationZeroPoint::PerGroup { group_size: 8 },
1035                ..
1036            }
1037        ));
1038    }
1039
1040    #[test]
1041    fn typed_affine_grouped_planner_evidence_records_matmul_and_dequant_contract() {
1042        let spec = QuantizedLinear4BitSpec::affine_grouped(256, 4096, 64);
1043        let evidence = linear_4bit_affine_grouped_planner_evidence(&spec)
1044            .expect("Fix: release grouped INT4 evidence must build");
1045
1046        assert_eq!(evidence.in_dim, 256);
1047        assert_eq!(evidence.out_dim, 4096);
1048        assert_eq!(evidence.group_size, 64);
1049        assert_eq!(evidence.group_count, 4);
1050        assert_eq!(evidence.packed_weight_bytes, 524_288);
1051        assert_eq!(evidence.dequantized_weight_bytes, 4_194_304);
1052        assert_eq!(
1053            evidence.dequant_bytes_elided,
1054            evidence.dequantized_weight_bytes
1055        );
1056        assert_eq!(evidence.sidecar_bytes, 131_072);
1057        assert_eq!(evidence.bias_bytes, 16_384);
1058        assert_eq!(evidence.output_bytes, 16_384);
1059        assert_eq!(evidence.matmul_m, 4096);
1060        assert_eq!(evidence.matmul_k, 256);
1061        assert_eq!(evidence.matmul_n, 1);
1062        assert_eq!(evidence.matmul_tile, AFFINE_GROUPED_LANES_PER_OUTPUT);
1063        assert_eq!(evidence.matmul_selected_path, "cooperative");
1064        assert_eq!(evidence.matmul_candidate_path, None);
1065        assert_eq!(
1066            evidence.matmul_fallback_reason,
1067            Some("strict_f32_requested")
1068        );
1069        assert!(!evidence.tensor_core_eligible);
1070        assert_eq!(
1071            evidence.output_drift_abs_tolerance,
1072            LINEAR_4BIT_AFFINE_GROUPED_OUTPUT_DRIFT_ABS_TOLERANCE
1073        );
1074    }
1075
1076    #[test]
1077    fn typed_affine_grouped_builder_rejects_mismatched_quantized_metadata() {
1078        let bad_storage = QuantizedLinear4BitSpec {
1079            in_dim: 32,
1080            out_dim: 4,
1081            weight_type: DataType::Quantized {
1082                storage: Box::new(DataType::I8),
1083                scale: QuantizationScale::PerGroup { group_size: 8 },
1084                zero_point: QuantizationZeroPoint::PerGroup { group_size: 8 },
1085            },
1086        };
1087        let error =
1088            linear_4bit_affine_grouped_typed(&bad_storage, "x", "w", "scale", "zp", "b", "out")
1089                .unwrap_err();
1090        assert!(
1091            error.contains("storage I4"),
1092            "Fix: storage mismatch should be explicit: {error}"
1093        );
1094
1095        let bad_sidecar = QuantizedLinear4BitSpec {
1096            in_dim: 32,
1097            out_dim: 4,
1098            weight_type: DataType::Quantized {
1099                storage: Box::new(DataType::I4),
1100                scale: QuantizationScale::PerGroup { group_size: 8 },
1101                zero_point: QuantizationZeroPoint::PerGroup { group_size: 16 },
1102            },
1103        };
1104        let error =
1105            linear_4bit_affine_grouped_typed(&bad_sidecar, "x", "w", "scale", "zp", "b", "out")
1106                .unwrap_err();
1107        assert!(
1108            error.contains("group sizes to match"),
1109            "Fix: sidecar mismatch should be explicit: {error}"
1110        );
1111    }
1112
1113    #[test]
1114    fn generated_typed_affine_grouped_specs_build_or_reject_by_metadata_contract() {
1115        let mut accepted = 0usize;
1116        let mut rejected = 0usize;
1117        for in_dim in [8u32, 10, 16, 18, 24, 32, 64, 128] {
1118            for out_dim in [1u32, 2, 3, 7, 16, 31] {
1119                for group_size in [1u32, 2, 4, 8, 16, 32] {
1120                    let spec = QuantizedLinear4BitSpec::affine_grouped(in_dim, out_dim, group_size);
1121                    let result = linear_4bit_affine_grouped_typed(
1122                        &spec, "x", "w", "scale", "zp", "b", "out",
1123                    );
1124                    if in_dim % 8 == 0 {
1125                        let program = result.expect("Fix: generated valid typed spec must build");
1126                        let output = &program.buffers()[5];
1127                        assert!(
1128                            output.count() >= out_dim,
1129                            "Fix: grouped INT4 output storage must cover the logical outputs after launch padding."
1130                        );
1131                        assert_eq!(
1132                            output.output_byte_range(),
1133                            Some(0..(out_dim as usize * core::mem::size_of::<f32>())),
1134                            "Fix: grouped INT4 output byte range must trim padded launch storage to the logical tensor."
1135                        );
1136                        accepted += 1;
1137                    } else {
1138                        let error = result.expect_err(
1139                            "Fix: generated indivisible typed spec must reject before dispatch",
1140                        );
1141                        assert!(error.contains("divisible by 8"));
1142                        rejected += 1;
1143                    }
1144                }
1145            }
1146        }
1147
1148        assert!(
1149            accepted + rejected >= 216,
1150            "Fix: generated typed quantized specs should cover hundreds of layouts"
1151        );
1152    }
1153
1154    #[test]
1155    fn generated_affine_grouped_vectors_match_cpu_oracle() {
1156        let mut checked = 0usize;
1157        for out_dim in [1u32, 2, 3, 5, 8, 13, 21, 32] {
1158            for group_size in [1u32, 2, 4, 8, 16, 32] {
1159                for seed in 0..48u32 {
1160                    let in_dim = 32u32;
1161                    let group_count = in_dim.div_ceil(group_size);
1162                    let x = (0..in_dim)
1163                        .map(|k| ((k.wrapping_mul(3).wrapping_add(seed)) % 19) as f32)
1164                        .collect::<Vec<_>>();
1165                    let mut packed = vec![0u32; (in_dim / 8 * out_dim) as usize];
1166                    for block in 0..(in_dim / 8) {
1167                        for out in 0..out_dim {
1168                            let mut word = 0u32;
1169                            for lane in 0..8 {
1170                                let k = block * 8 + lane;
1171                                let nibble = k
1172                                    .wrapping_mul(7)
1173                                    .wrapping_add(out.wrapping_mul(11))
1174                                    .wrapping_add(seed)
1175                                    & 0xF;
1176                                word |= nibble << (lane * 4);
1177                            }
1178                            packed[(block * out_dim + out) as usize] = word;
1179                        }
1180                    }
1181                    let mut scale = vec![0.0f32; (group_count * out_dim) as usize];
1182                    let mut zero_point = vec![0u32; (group_count * out_dim) as usize];
1183                    for group in 0..group_count {
1184                        for out in 0..out_dim {
1185                            let idx = (group * out_dim + out) as usize;
1186                            scale[idx] = match (group + out + seed) & 3 {
1187                                0 => 0.25,
1188                                1 => 0.5,
1189                                2 => 1.0,
1190                                _ => 2.0,
1191                            };
1192                            zero_point[idx] =
1193                                group.wrapping_mul(5).wrapping_add(out).wrapping_add(seed) & 0xF;
1194                        }
1195                    }
1196                    let bias = (0..out_dim)
1197                        .map(|out| ((out + seed) & 7) as f32)
1198                        .collect::<Vec<_>>();
1199
1200                    let program = linear_4bit_affine_grouped(
1201                        "x", "w", "scale", "zp", "b", "out", in_dim, out_dim, group_size,
1202                    )
1203                    .expect("Fix: generated affine grouped fixture must build");
1204                    let outputs = vyre_reference::reference_eval(
1205                        &program,
1206                        &[
1207                            Value::from(f32_bytes(&x)),
1208                            Value::from(u32_bytes(&packed)),
1209                            Value::from(f32_bytes(&scale)),
1210                            Value::from(u32_bytes(&zero_point)),
1211                            Value::from(f32_bytes(&bias)),
1212                            Value::from(vec![0u8; out_dim as usize * 4]),
1213                        ],
1214                    )
1215                    .unwrap_or_else(|error| {
1216                        panic!(
1217                            "Fix: generated affine grouped fixture must execute for out_dim={out_dim}, group_size={group_size}, seed={seed}: {error}"
1218                        )
1219                    });
1220                    let actual =
1221                        vyre_primitives::wire::decode_f32_le_bytes_all(&outputs[0].to_bytes());
1222                    let expected = affine_cpu_reference(
1223                        &x,
1224                        &packed,
1225                        &scale,
1226                        &zero_point,
1227                        &bias,
1228                        in_dim,
1229                        out_dim,
1230                        group_size,
1231                    );
1232
1233                    assert_eq!(
1234                        actual, expected,
1235                        "generated affine grouped vector mismatch for out_dim={out_dim}, group_size={group_size}, seed={seed}"
1236                    );
1237                    checked += out_dim as usize;
1238                }
1239            }
1240        }
1241
1242        assert!(
1243            checked >= 24_000,
1244            "Fix: generated affine grouped coverage should exercise tens of thousands of output vectors, got {checked}"
1245        );
1246    }
1247}
1248
1249inventory::submit! {
1250    crate::harness::OpEntry {
1251        id: "vyre-libs::nn::linear_4bit",
1252        build: || {
1253            linear_4bit("x", "w", "b", "out", 8, 4).unwrap_or_else(|error| {
1254                crate::builder::invalid_output_program(
1255                    "vyre-libs::nn::linear_4bit",
1256                    "out",
1257                    DataType::F32,
1258                    error,
1259                )
1260            })
1261        },
1262        test_inputs: Some(|| {
1263            let x: Vec<f32> = (0..8).map(|i| i as f32).collect();
1264            let w: Vec<u32> = vec![0x7654_3210, 0xFEDC_BA98, 0x1111_1111, 0x0000_0000];
1265            let b: Vec<f32> = vec![0.0; 4];
1266            vec![vec![
1267                vyre_primitives::wire::pack_f32_slice(&x),
1268                vyre_primitives::wire::pack_u32_slice(&w),
1269                vyre_primitives::wire::pack_f32_slice(&b),
1270            ]]
1271        }),
1272        expected_output: Some(|| {
1273            let out = [140.0f32, 364.0, 28.0, 0.0];
1274            vec![vec![vyre_primitives::wire::pack_f32_slice(&out)]]
1275        }),
1276        category: Some("nn"),
1277    }
1278}
1279
1280inventory::submit! {
1281    crate::harness::OpEntry {
1282        id: "vyre-libs::nn::linear_4bit_affine_grouped",
1283        build: || {
1284            linear_4bit_affine_grouped("x", "w", "scale", "zp", "b", "out", 8, 2, 4)
1285                .unwrap_or_else(|error| {
1286                    crate::builder::invalid_output_program(
1287                        "vyre-libs::nn::linear_4bit_affine_grouped",
1288                        "out",
1289                        DataType::F32,
1290                        error,
1291                    )
1292                })
1293        },
1294        test_inputs: Some(|| {
1295            let x = [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
1296            let w = [0x8765_4321u32, 0x0000_0000u32];
1297            let scale = [0.5f32, 1.0, 2.0, 1.0];
1298            let zp = [1u32, 0, 4, 0];
1299            let b = [0.0f32, 3.0];
1300            vec![vec![
1301                vyre_primitives::wire::pack_f32_slice(&x),
1302                vyre_primitives::wire::pack_u32_slice(&w),
1303                vyre_primitives::wire::pack_f32_slice(&scale),
1304                vyre_primitives::wire::pack_u32_slice(&zp),
1305                vyre_primitives::wire::pack_f32_slice(&b),
1306            ]]
1307        }),
1308        expected_output: Some(|| {
1309            let out = [150.0f32, 3.0];
1310            vec![vec![vyre_primitives::wire::pack_f32_slice(&out)]]
1311        }),
1312        category: Some("nn"),
1313    }
1314}