Skip to main content

teeny_quant/quant/
pack4.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//! INT4 nibble packing.
18//!
19//! safetensors 0.7 has no native `I4`/`U4` dtype, so packed INT4 tensors are stored as plain
20//! `U8`. **Packing layout** (this crate's own convention -- not bit-for-bit compatible with any
21//! particular GPTQ/AWQ int32-packing scheme, which vary across implementations and versions):
22//! two consecutive elements in row-major order share one byte, the first in the low nibble and
23//! the second in the high nibble, each a 4-bit two's-complement value in `-8..=7`. A tensor with
24//! an odd element count gets one trailing byte whose high nibble is unused padding (`0`). The
25//! packed tensor's logical shape and element count are recorded separately in the
26//! `quantization_config` metadata (see [`crate::format`]) since the packed `U8` tensor's own
27//! shape (`[ceil(n / 2)]`) doesn't reflect it.
28
29/// Packs 4-bit two's-complement values (each expected to be in `-8..=7`; out-of-range bits above
30/// the low nibble are silently dropped) two-per-byte, low nibble first.
31pub fn pack_i4(values: &[i8]) -> Vec<u8> {
32    let mut out = Vec::with_capacity(values.len().div_ceil(2));
33    for pair in values.chunks(2) {
34        let lo = (pair[0] as u8) & 0x0F;
35        let hi = pair.get(1).map(|&v| (v as u8) & 0x0F).unwrap_or(0);
36        out.push(lo | (hi << 4));
37    }
38    out
39}
40
41/// Inverse of [`pack_i4`]: unpacks `n` sign-extended 4-bit values from `bytes`.
42pub fn unpack_i4(bytes: &[u8], n: usize) -> Vec<i8> {
43    fn sign_extend(nibble: u8) -> i8 {
44        if nibble >= 8 {
45            nibble as i8 - 16
46        } else {
47            nibble as i8
48        }
49    }
50
51    let mut out = Vec::with_capacity(n);
52    for &byte in bytes {
53        if out.len() >= n {
54            break;
55        }
56        out.push(sign_extend(byte & 0x0F));
57        if out.len() < n {
58            out.push(sign_extend((byte >> 4) & 0x0F));
59        }
60    }
61    out
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67
68    #[test]
69    fn round_trips_full_int4_range() {
70        let values: Vec<i8> = (-8..=7).collect();
71        let packed = pack_i4(&values);
72        assert_eq!(packed.len(), values.len().div_ceil(2));
73        let unpacked = unpack_i4(&packed, values.len());
74        assert_eq!(values, unpacked);
75    }
76
77    #[test]
78    fn odd_length_pads_trailing_nibble() {
79        let values = vec![-8i8, 7, -1];
80        let packed = pack_i4(&values);
81        assert_eq!(packed.len(), 2);
82        let unpacked = unpack_i4(&packed, values.len());
83        assert_eq!(values, unpacked);
84    }
85
86    #[test]
87    fn empty_round_trips() {
88        let values: Vec<i8> = vec![];
89        let packed = pack_i4(&values);
90        assert!(packed.is_empty());
91        assert!(unpack_i4(&packed, 0).is_empty());
92    }
93}