Skip to main content

teeny_quant/
format.rs

1/*
2 * Copyright (c) 2026 Teenygrad.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *   http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17//! Output convention: [vllm-project/compressed-tensors](https://github.com/vllm-project/compressed-tensors)
18//! layered on plain `.safetensors`, so quantized checkpoints stay loadable by existing HF/vLLM
19//! tooling for INT8/FP8. For a quantized weight tensor named `foo.weight`:
20//!
21//! - `foo.weight` itself becomes the quantized values (`I8`, packed `U8` for INT4, or
22//!   `F8_E4M3`/`F8_E5M2`).
23//! - `foo.weight_scale` holds one `F32` scale per group (flattened to 1-D, length = number of
24//!   groups -- see [`crate::quant::compute_groups`] for how elements map to groups).
25//! - `foo.weight_zero_point` holds one `I32` zero-point per group, only for asymmetric schemes
26//!   (symmetric schemes have an implicit zero-point of `0` and omit this tensor).
27//! - A `quantization_config` JSON blob in the file's `__metadata__` header describes the scheme
28//!   (`config_groups`), which tensors were left unquantized (`ignore`), and -- since this
29//!   crate's INT4 packing (see [`crate::quant::pack4`]) doesn't match compressed-tensors' own
30//!   int32-based `pack-quantized` layout -- a `teenygrad_packed_int4` extension recording each
31//!   packed tensor's true logical shape.
32//!
33//! Tensors are only quantized if they're rank >= 2 (see [`should_quantize`]) -- 1-D tensors
34//! (biases, norm weights) are passed through unchanged and listed in `ignore`, matching common
35//! PTQ tooling's default of leaving those alone.
36
37use std::collections::HashMap;
38
39use safetensors::Dtype;
40use safetensors::tensor::TensorView;
41use serde::{Deserialize, Serialize};
42
43use crate::error::Result;
44use crate::quant::pack4::pack_i4;
45use crate::quant::{Fp8Variant, Granularity, Scheme, dequantize_affine, dequantize_fp8};
46use crate::quant::{quantize_affine, quantize_fp8};
47use crate::read::is_quantizable_float;
48use crate::write::OutputTensor;
49
50/// The compressed-tensors `weights` block for one scheme.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct WeightsConfig {
53    /// Bits per quantized element.
54    pub num_bits: u8,
55    /// `"int"` or `"float"`.
56    #[serde(rename = "type")]
57    pub type_: String,
58    /// Whether `zero_point` is implicitly `0` (no `_zero_point` tensor is written).
59    pub symmetric: bool,
60    /// `"tensor"`, `"channel"`, or `"group"` (see [`Granularity::strategy_name`]).
61    pub strategy: String,
62    /// The axis grouping runs along, present for `"channel"`/`"group"` strategies. Required to
63    /// reconstruct the exact element->group mapping (see [`crate::quant::compute_groups`]) --
64    /// `strategy` and `group_size` alone are ambiguous for tensors with rank > 2.
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub axis: Option<usize>,
67    /// Elements per group, only present when `strategy == "group"`.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub group_size: Option<usize>,
70    /// `"e4m3"` or `"e5m2"`, present only for `type == "float"`. The two FP8 encodings are
71    /// *not* bit-compatible with each other, so this is required to correctly decode -- without
72    /// it, [`crate::validate`] would have to guess the variant used to write the file.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub fp8_variant: Option<String>,
75}
76
77/// One compressed-tensors config group: a scheme plus the module types it applies to.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct ConfigGroup {
80    /// The quantization scheme for this group.
81    pub weights: WeightsConfig,
82    /// Module-type targets this group applies to. `teeny-quant` operates on raw tensor names
83    /// rather than a module graph, so this is always `["*"]` today -- a placeholder for when a
84    /// real module-type mapping (e.g. via the ONNX graph) becomes available.
85    pub targets: Vec<String>,
86}
87
88/// Logical shape of a nibble-packed INT4 tensor, since the packed `U8` tensor's own shape
89/// (`[ceil(n / 2)]`) doesn't reflect it. See [`crate::quant::pack4`].
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct PackedTensorInfo {
92    /// The tensor's shape before packing.
93    pub logical_shape: Vec<usize>,
94    /// `logical_shape.iter().product()`, for convenience.
95    pub elements: usize,
96}
97
98/// The full `quantization_config` metadata blob embedded in the output `.safetensors` header.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct QuantizationConfig {
101    /// Always `"compressed-tensors"`.
102    pub quant_method: String,
103    /// `"int-quantized"`, `"float-quantized"`, or `"pack-quantized"`.
104    pub format: String,
105    /// Named config groups (today, always a single `"group_0"`).
106    pub config_groups: HashMap<String, ConfigGroup>,
107    /// Tensor names left unquantized (see [`should_quantize`]).
108    #[serde(default, skip_serializing_if = "Vec::is_empty")]
109    pub ignore: Vec<String>,
110    /// `teeny-quant` extension: logical shape for each nibble-packed INT4 tensor.
111    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
112    pub teenygrad_packed_int4: HashMap<String, PackedTensorInfo>,
113}
114
115/// The metadata key `quantization_config` is stored under in the `.safetensors` header.
116pub const QUANTIZATION_CONFIG_KEY: &str = "quantization_config";
117
118/// `foo.weight` -> `foo.weight_scale`.
119pub fn scale_tensor_name(weight_name: &str) -> String {
120    format!("{weight_name}_scale")
121}
122
123/// `foo.weight` -> `foo.weight_zero_point`.
124pub fn zero_point_tensor_name(weight_name: &str) -> String {
125    format!("{weight_name}_zero_point")
126}
127
128/// Whether a tensor should be quantized: rank >= 2 (so biases/norm weights are left alone) and a
129/// dtype [`crate::read::read_f32`] can upcast from.
130pub fn should_quantize(shape: &[usize], dtype: Dtype) -> bool {
131    shape.len() >= 2 && is_quantizable_float(dtype)
132}
133
134fn f32_vec_to_le_bytes(values: &[f32]) -> Vec<u8> {
135    values.iter().flat_map(|v| v.to_le_bytes()).collect()
136}
137
138fn i32_vec_to_le_bytes(values: &[i32]) -> Vec<u8> {
139    values.iter().flat_map(|v| v.to_le_bytes()).collect()
140}
141
142/// Quantizes one tensor per `scheme`/`granularity`, returning the output tensors to insert
143/// (quantized weight, `_scale`, and -- for asymmetric schemes -- `_zero_point`), keyed by their
144/// final names. `name` is the *original* (unsuffixed) tensor name.
145pub fn quantize_tensor(
146    name: &str,
147    data: &[f32],
148    shape: &[usize],
149    scheme: Scheme,
150    granularity: Granularity,
151) -> Result<HashMap<String, OutputTensor>> {
152    let mut out = HashMap::with_capacity(3);
153
154    match scheme {
155        Scheme::Int8 { symmetric } => {
156            let q = quantize_affine(name, data, shape, granularity, symmetric, 8)?;
157            let qbytes: Vec<u8> = q.qvalues.iter().map(|&v| v as u8).collect();
158            out.insert(
159                name.to_string(),
160                OutputTensor {
161                    dtype: Dtype::I8,
162                    shape: shape.to_vec(),
163                    data: qbytes,
164                },
165            );
166            let scales: Vec<f32> = q.params.iter().map(|p| p.scale).collect();
167            out.insert(
168                scale_tensor_name(name),
169                OutputTensor {
170                    dtype: Dtype::F32,
171                    shape: vec![scales.len()],
172                    data: f32_vec_to_le_bytes(&scales),
173                },
174            );
175            if !symmetric {
176                let zps: Vec<i32> = q.params.iter().map(|p| p.zero_point).collect();
177                out.insert(
178                    zero_point_tensor_name(name),
179                    OutputTensor {
180                        dtype: Dtype::I32,
181                        shape: vec![zps.len()],
182                        data: i32_vec_to_le_bytes(&zps),
183                    },
184                );
185            }
186        }
187        Scheme::Int4 { symmetric } => {
188            let q = quantize_affine(name, data, shape, granularity, symmetric, 4)?;
189            let packed = pack_i4(&q.qvalues);
190            out.insert(
191                name.to_string(),
192                OutputTensor {
193                    dtype: Dtype::U8,
194                    shape: vec![packed.len()],
195                    data: packed,
196                },
197            );
198            let scales: Vec<f32> = q.params.iter().map(|p| p.scale).collect();
199            out.insert(
200                scale_tensor_name(name),
201                OutputTensor {
202                    dtype: Dtype::F32,
203                    shape: vec![scales.len()],
204                    data: f32_vec_to_le_bytes(&scales),
205                },
206            );
207            if !symmetric {
208                let zps: Vec<i32> = q.params.iter().map(|p| p.zero_point).collect();
209                out.insert(
210                    zero_point_tensor_name(name),
211                    OutputTensor {
212                        dtype: Dtype::I32,
213                        shape: vec![zps.len()],
214                        data: i32_vec_to_le_bytes(&zps),
215                    },
216                );
217            }
218        }
219        Scheme::Fp8 { variant } => {
220            let q = quantize_fp8(data, shape, granularity, variant);
221            let dtype = match variant {
222                Fp8Variant::E4M3 => Dtype::F8_E4M3,
223                Fp8Variant::E5M2 => Dtype::F8_E5M2,
224            };
225            out.insert(
226                name.to_string(),
227                OutputTensor {
228                    dtype,
229                    shape: shape.to_vec(),
230                    data: q.qvalues.clone(),
231                },
232            );
233            out.insert(
234                scale_tensor_name(name),
235                OutputTensor {
236                    dtype: Dtype::F32,
237                    shape: vec![q.scales.len()],
238                    data: f32_vec_to_le_bytes(&q.scales),
239                },
240            );
241        }
242    }
243
244    Ok(out)
245}
246
247/// Dequantizes a tensor previously written by [`quantize_tensor`], given its already-decoded
248/// scale (and, for asymmetric schemes, zero-point) values. Used by [`crate::validate`].
249pub fn dequantize_tensor(
250    scheme: Scheme,
251    qview: &TensorView<'_>,
252    shape: &[usize],
253    scales: &[f32],
254    zero_points: Option<&[i32]>,
255    groups: &[u32],
256) -> Vec<f32> {
257    match scheme {
258        Scheme::Int8 { .. } | Scheme::Int4 { .. } => {
259            let qvalues: Vec<i8> = match scheme {
260                Scheme::Int4 { .. } => {
261                    let n: usize = shape.iter().product();
262                    crate::quant::pack4::unpack_i4(qview.data(), n)
263                }
264                _ => qview.data().iter().map(|&b| b as i8).collect(),
265            };
266            let params: Vec<crate::quant::AffineParams> = scales
267                .iter()
268                .enumerate()
269                .map(|(i, &scale)| crate::quant::AffineParams {
270                    scale,
271                    zero_point: zero_points.map(|z| z[i]).unwrap_or(0),
272                })
273                .collect();
274            dequantize_affine(&crate::quant::QuantizedAffine {
275                qvalues,
276                params,
277                groups: groups.to_vec(),
278            })
279        }
280        Scheme::Fp8 { variant } => dequantize_fp8(&crate::quant::QuantizedFp8 {
281            qvalues: qview.data().to_vec(),
282            scales: scales.to_vec(),
283            groups: groups.to_vec(),
284            variant,
285        }),
286    }
287}
288
289/// Assembles the full [`QuantizationConfig`] for a checkpoint quantized uniformly with
290/// `scheme`/`granularity`.
291pub fn build_config(
292    scheme: Scheme,
293    granularity: Granularity,
294    ignored: Vec<String>,
295    packed_int4: HashMap<String, PackedTensorInfo>,
296) -> QuantizationConfig {
297    let format = match scheme {
298        Scheme::Int8 { .. } => "int-quantized",
299        Scheme::Int4 { .. } => "pack-quantized",
300        Scheme::Fp8 { .. } => "float-quantized",
301    };
302
303    let group_size = match granularity {
304        Granularity::Group { group_size, .. } => Some(group_size),
305        _ => None,
306    };
307    let axis = granularity.axis_and_group_size().map(|(axis, _)| axis);
308    let fp8_variant = match scheme {
309        Scheme::Fp8 {
310            variant: Fp8Variant::E4M3,
311        } => Some("e4m3".to_string()),
312        Scheme::Fp8 {
313            variant: Fp8Variant::E5M2,
314        } => Some("e5m2".to_string()),
315        _ => None,
316    };
317
318    let weights = WeightsConfig {
319        num_bits: scheme.num_bits(),
320        type_: scheme.type_name().to_string(),
321        symmetric: scheme.is_symmetric(),
322        strategy: granularity.strategy_name().to_string(),
323        axis,
324        group_size,
325        fp8_variant,
326    };
327
328    let mut config_groups = HashMap::new();
329    config_groups.insert(
330        "group_0".to_string(),
331        ConfigGroup {
332            weights,
333            targets: vec!["*".to_string()],
334        },
335    );
336
337    QuantizationConfig {
338        quant_method: "compressed-tensors".to_string(),
339        format: format.to_string(),
340        config_groups,
341        ignore: ignored,
342        teenygrad_packed_int4: packed_int4,
343    }
344}
345
346/// Serializes `config` into the `.safetensors` string-metadata map under
347/// [`QUANTIZATION_CONFIG_KEY`].
348pub fn config_to_metadata(config: &QuantizationConfig) -> Result<HashMap<String, String>> {
349    let mut metadata = HashMap::new();
350    metadata.insert(
351        QUANTIZATION_CONFIG_KEY.to_string(),
352        serde_json::to_string(config)?,
353    );
354    Ok(metadata)
355}
356
357/// Parses a `quantization_config` blob previously produced by [`config_to_metadata`].
358pub fn config_from_metadata(
359    metadata: &HashMap<String, String>,
360) -> Result<Option<QuantizationConfig>> {
361    match metadata.get(QUANTIZATION_CONFIG_KEY) {
362        Some(json) => Ok(Some(serde_json::from_str(json)?)),
363        None => Ok(None),
364    }
365}