Skip to main content

embedded_nn/
subbyte.rs

1//! Sub-byte 4-bit (`s4`) quantization operations and layers.
2
3use crate::support::{clamp, requantize};
4use crate::types::{ConvParams, Dims, FcParams, PerTensorQuantParams, Result};
5
6/// Unpacks a byte containing two 4-bit signed integers into `(low_nibble, high_nibble)`.
7///
8/// Both nibbles are sign-extended into `i8` values in the range `[-8, 7]`.
9#[inline]
10pub const fn unpack_s4_pair(packed_byte: i8) -> (i8, i8) {
11    let b = packed_byte as i32;
12    let low = (b << 28) >> 28;
13    let high = b >> 4;
14    (low as i8, high as i8)
15}
16
17/// Packs two 4-bit signed integers `(low, high)` in the range `[-8, 7]` into a single byte.
18#[inline]
19pub const fn pack_s4_pair(low: i8, high: i8) -> i8 {
20    ((low as u8 & 0x0F) | ((high as u8 & 0x0F) << 4)) as i8
21}
22
23/// Performs per-tensor quantized int4 (`s4`) Fully Connected layer.
24///
25/// `packed_kernel` contains pairs of 4-bit signed weights packed into bytes.
26pub fn fully_connected_s4(
27    fc_params: &FcParams,
28    quant_params: &PerTensorQuantParams,
29    input_dims: &Dims,
30    input: &[i8],
31    filter_dims: &Dims,
32    packed_kernel: &[i8],
33    bias: Option<&[i32]>,
34    output_dims: &Dims,
35    output: &mut [i8],
36) -> Result<()> {
37    let batches = input_dims.n as usize;
38    let accum_depth = filter_dims.n as usize;
39    let output_depth = output_dims.c as usize;
40
41    let packed_cols = (accum_depth + 1) / 2;
42
43    for b in 0..batches {
44        let input_batch = &input[b * accum_depth..(b + 1) * accum_depth];
45        let output_batch = &mut output[b * output_depth..(b + 1) * output_depth];
46
47        for out_c in 0..output_depth {
48            let mut acc: i32 = match bias {
49                Some(b_slice) => b_slice[out_c],
50                None => 0,
51            };
52
53            let kernel_row_packed = &packed_kernel[out_c * packed_cols..(out_c + 1) * packed_cols];
54
55            for p in 0..packed_cols {
56                let (w0, w1) = unpack_s4_pair(kernel_row_packed[p]);
57                let idx0 = p * 2;
58                if idx0 < accum_depth {
59                    let lhs0 = input_batch[idx0] as i32 + fc_params.input_offset;
60                    acc += lhs0 * (w0 as i32);
61                }
62                let idx1 = idx0 + 1;
63                if idx1 < accum_depth {
64                    let lhs1 = input_batch[idx1] as i32 + fc_params.input_offset;
65                    acc += lhs1 * (w1 as i32);
66                }
67            }
68
69            acc = requantize(acc, quant_params.multiplier, quant_params.shift);
70            acc += fc_params.output_offset;
71            acc = clamp(acc, fc_params.activation.min, fc_params.activation.max);
72
73            output_batch[out_c] = acc as i8;
74        }
75    }
76    Ok(())
77}
78
79/// Performs per-tensor quantized int4 (`s4`) 2D Convolution layer.
80pub fn convolve_s4(
81    conv_params: &ConvParams,
82    quant_params: &PerTensorQuantParams,
83    input_dims: &Dims,
84    input: &[i8],
85    filter_dims: &Dims,
86    packed_kernel: &[i8],
87    bias: Option<&[i32]>,
88    output_dims: &Dims,
89    output: &mut [i8],
90) -> Result<()> {
91    let input_batches = input_dims.n as usize;
92    let input_h = input_dims.h as usize;
93    let input_w = input_dims.w as usize;
94    let input_c = input_dims.c as usize;
95
96    let kernel_h = filter_dims.h as usize;
97    let kernel_w = filter_dims.w as usize;
98    let kernel_c = filter_dims.c as usize;
99
100    let output_h = output_dims.h as usize;
101    let output_w = output_dims.w as usize;
102    let output_c = output_dims.c as usize;
103
104    let kernel_spatial_c = kernel_h * kernel_w * kernel_c;
105    let packed_kernel_cols = (kernel_spatial_c + 1) / 2;
106
107    for b in 0..input_batches {
108        for out_y in 0..output_h {
109            let base_y = out_y as i32 * conv_params.stride.h - conv_params.padding.h;
110            for out_x in 0..output_w {
111                let base_x = out_x as i32 * conv_params.stride.w - conv_params.padding.w;
112
113                for out_c in 0..output_c {
114                    let mut acc: i32 = match bias {
115                        Some(b_slice) => b_slice[out_c],
116                        None => 0,
117                    };
118
119                    let ker_packed_row = &packed_kernel
120                        [out_c * packed_kernel_cols..(out_c + 1) * packed_kernel_cols];
121                    let mut k_flat_idx = 0usize;
122
123                    for ky in 0..kernel_h {
124                        let in_y = base_y + ky as i32 * conv_params.dilation.h;
125                        for kx in 0..kernel_w {
126                            let in_x = base_x + kx as i32 * conv_params.dilation.w;
127                            for ic in 0..kernel_c {
128                                if in_y >= 0
129                                    && in_y < input_dims.h
130                                    && in_x >= 0
131                                    && in_x < input_dims.w
132                                {
133                                    let in_idx = ((b * input_h + in_y as usize) * input_w
134                                        + in_x as usize)
135                                        * input_c
136                                        + ic;
137
138                                    let packed_byte = ker_packed_row[k_flat_idx / 2];
139                                    let weight = if k_flat_idx % 2 == 0 {
140                                        unpack_s4_pair(packed_byte).0
141                                    } else {
142                                        unpack_s4_pair(packed_byte).1
143                                    };
144
145                                    let lhs = input[in_idx] as i32 + conv_params.input_offset;
146                                    acc += lhs * (weight as i32);
147                                }
148                                k_flat_idx += 1;
149                            }
150                        }
151                    }
152
153                    acc = requantize(acc, quant_params.multiplier, quant_params.shift);
154                    acc += conv_params.output_offset;
155                    acc = clamp(acc, conv_params.activation.min, conv_params.activation.max);
156
157                    let out_idx = ((b * output_h + out_y) * output_w + out_x) * output_c + out_c;
158                    output[out_idx] = acc as i8;
159                }
160            }
161        }
162    }
163
164    Ok(())
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::types::Activation;
171
172    #[test]
173    fn test_s4_packing() {
174        let (low, high) = (-5i8, 7i8);
175        let packed = pack_s4_pair(low, high);
176        let (unpacked_low, unpacked_high) = unpack_s4_pair(packed);
177        assert_eq!(unpacked_low, -5);
178        assert_eq!(unpacked_high, 7);
179    }
180
181    #[test]
182    fn test_fully_connected_s4() {
183        let fc_params = FcParams {
184            input_offset: 0,
185            filter_offset: 0,
186            output_offset: 0,
187            activation: Activation::int8_unconstrained(),
188        };
189        let quant_params = PerTensorQuantParams::new(1073741824, 0); // 0.5
190        let input_dims = Dims::new(1, 1, 1, 2);
191        let input = [4i8, 6i8];
192
193        let filter_dims = Dims::new(2, 1, 1, 1);
194        let packed_kernel = [pack_s4_pair(2i8, 3i8)]; // w0 = 2, w1 = 3
195        let output_dims = Dims::new(1, 1, 1, 1);
196        let mut output = [0i8; 1];
197
198        fully_connected_s4(
199            &fc_params,
200            &quant_params,
201            &input_dims,
202            &input,
203            &filter_dims,
204            &packed_kernel,
205            None,
206            &output_dims,
207            &mut output,
208        )
209        .unwrap();
210
211        // 4*2 + 6*3 = 26. Requantized * 0.5 = 13
212        assert_eq!(output[0], 13);
213    }
214}