Skip to main content

kopitiam_tensor/tensor/
gather.rs

1//! Embedding-style row gather.
2
3use kopitiam_core::{DType, Error, Result, Shape};
4
5use crate::storage::Storage;
6
7use super::Tensor;
8
9impl Tensor {
10    /// Gathers rows of a `[vocab, hidden]` embedding table by token id.
11    ///
12    /// `indices` (dtype [`DType::I32`], any shape) holds token ids in
13    /// `[0, vocab)`. The result has shape `indices.shape() + [hidden]` —
14    /// every index becomes one full row of `self`.
15    ///
16    /// # Why this is not `torch.gather`
17    ///
18    /// PyTorch's `gather(dim, index)` requires `index` to have the *same
19    /// rank* as the source and picks one scalar per output position along
20    /// `dim`. That is a general primitive this crate deliberately does not
21    /// implement: nothing in a forward pass needs it. What every
22    /// transformer *does* need is embedding lookup — pick whole rows out
23    /// of a `[vocab, hidden]` table by a batch of token ids — which is a
24    /// different (simpler, more common) shape of operation with its own
25    /// name here rather than a special case of a more general `gather`
26    /// that this crate does not otherwise use.
27    ///
28    /// # Quantized tables are read in place
29    ///
30    /// A block-quantized table is gathered from directly, decoding only the
31    /// requested rows — see [`Tensor::gather_rows_quantized`]. The result is
32    /// always `f32` either way, so callers cannot tell the difference except in
33    /// memory use.
34    pub fn gather_rows(&self, indices: &Tensor) -> Result<Tensor> {
35        indices.require_dtype(DType::I32)?;
36        if self.rank() != 2 {
37            return Err(Error::ShapeMismatch { expected: self.shape.clone(), actual: self.shape.clone() });
38        }
39        // Decode only the requested rows when the table is still quantized —
40        // see `gather_rows_quantized` for why that matters.
41        if let Storage::Quantized { dtype, bytes } = self.storage.as_ref() {
42            return self.gather_rows_quantized(indices, *dtype, bytes);
43        }
44        self.require_dtype(DType::F32)?;
45        self.gather_rows_f32(indices)
46    }
47
48    /// Gathers rows out of a **block-quantized** table, decoding only the rows
49    /// actually asked for.
50    ///
51    /// # Why this exists
52    ///
53    /// An embedding table is the largest tensor in a small model — SmolLM2-360M's
54    /// is `49152 x 960` — and a forward pass reads a handful of its rows: one per
55    /// prompt token, often exactly one during decode. Dequantizing the whole
56    /// table to `f32` just to read those rows costs 188 MB of resident memory
57    /// against 47 MB of Q8_0, a 4x expansion of the single biggest allocation in
58    /// the model, and it is pure waste on a phone.
59    ///
60    /// Rows are addressable because a row is a whole number of blocks: with
61    /// `hidden` a multiple of the dtype's block size, row `r` occupies exactly
62    /// `blocks_per_row` consecutive blocks starting at `r * blocks_per_row`. So
63    /// a row decodes on its own, with no need to touch its neighbours.
64    ///
65    /// Requires a contiguous, unoffset view — quantized bytes have no strides to
66    /// walk, so an arbitrary view could not be honoured, and silently ignoring
67    /// one would return the wrong rows.
68    fn gather_rows_quantized(
69        &self,
70        indices: &Tensor,
71        dtype: DType,
72        bytes: &[u8],
73    ) -> Result<Tensor> {
74        let vocab = self.shape.dims()[0];
75        let hidden = self.shape.dims()[1];
76        let block_size = dtype.block_size();
77        let block_bytes = dtype.block_bytes();
78
79        // A row that straddles a block boundary is not independently decodable,
80        // and neither is a strided view. Refuse rather than return wrong rows.
81        if !hidden.is_multiple_of(block_size)
82            || !self.is_contiguous()
83            || self.offset != 0
84        {
85            return Err(Error::UnsupportedDType { op: "gather_rows", dtype });
86        }
87        let blocks_per_row = hidden / block_size;
88        let row_bytes = blocks_per_row * block_bytes;
89
90        let Storage::I32(idx_data) = indices.storage.as_ref() else { unreachable!() };
91        let mut out = Vec::with_capacity(indices.elem_count() * hidden);
92        for offset in indices.logical_offsets() {
93            let id = idx_data[offset];
94            if id < 0 || id as usize >= vocab {
95                return Err(Error::IndexOutOfBounds {
96                    dim: 0,
97                    index: id.max(0) as usize,
98                    len: vocab,
99                });
100            }
101            let start = id as usize * row_bytes;
102            let row = bytes
103                .get(start..start + row_bytes)
104                .ok_or(Error::IndexOutOfBounds { dim: 0, index: id as usize, len: vocab })?;
105            out.extend(crate::quant::dequantize(dtype, row)?);
106        }
107
108        let mut out_dims = indices.shape.dims().to_vec();
109        out_dims.push(hidden);
110        Tensor::from_f32(out, Shape::new(out_dims))
111    }
112
113    /// The `f32` path: honours strides and offset, so a transposed or sliced
114    /// view of a table gathers correctly.
115    fn gather_rows_f32(&self, indices: &Tensor) -> Result<Tensor> {
116        let vocab = self.shape.dims()[0];
117        let hidden = self.shape.dims()[1];
118
119        let Storage::F32(data) = self.storage.as_ref() else { unreachable!() };
120        let Storage::I32(idx_data) = indices.storage.as_ref() else { unreachable!() };
121
122        let mut out = Vec::with_capacity(indices.elem_count() * hidden);
123        for offset in indices.logical_offsets() {
124            let id = idx_data[offset];
125            if id < 0 || id as usize >= vocab {
126                return Err(Error::IndexOutOfBounds {
127                    dim: 0,
128                    index: id.max(0) as usize,
129                    len: vocab,
130                });
131            }
132            let row_start = self.offset + id as usize * self.strides[0];
133            for c in 0..hidden {
134                out.push(data[row_start + c * self.strides[1]]);
135            }
136        }
137
138        let mut out_dims = indices.shape.dims().to_vec();
139        out_dims.push(hidden);
140        Tensor::from_f32(out, Shape::new(out_dims))
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn gather_rows_picks_the_requested_embedding_rows() {
150        // vocab=4, hidden=2: rows are [0,1], [2,3], [4,5], [6,7].
151        let table = Tensor::from_f32((0..8).map(|v| v as f32).collect(), [4, 2]).unwrap();
152        let ids = Tensor::from_i32(vec![2, 0, 3], [3]).unwrap();
153        let out = table.gather_rows(&ids).unwrap();
154        assert_eq!(out.shape().dims(), &[3, 2]);
155        assert_eq!(out.to_vec_f32().unwrap(), vec![4.0, 5.0, 0.0, 1.0, 6.0, 7.0]);
156    }
157
158    #[test]
159    fn gather_rows_preserves_the_indices_shape_for_batched_lookup() {
160        // A [batch=2, seq=2] block of token ids gathers into [2, 2, hidden].
161        let table = Tensor::from_f32((0..6).map(|v| v as f32).collect(), [3, 2]).unwrap();
162        let ids = Tensor::from_i32(vec![0, 1, 2, 0], [2, 2]).unwrap();
163        let out = table.gather_rows(&ids).unwrap();
164        assert_eq!(out.shape().dims(), &[2, 2, 2]);
165        assert_eq!(out.to_vec_f32().unwrap(), vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 0.0, 1.0]);
166    }
167
168    #[test]
169    fn gather_rows_works_through_a_transposed_view_of_the_table() {
170        // table stored as [hidden, vocab], transposed to [vocab, hidden]
171        // before gathering — exercises the strides[]/offset-aware path.
172        let table = Tensor::from_f32(vec![0.0, 2.0, 4.0, 6.0, 1.0, 3.0, 5.0, 7.0], [2, 4]).unwrap();
173        let transposed = table.transpose(0, 1).unwrap(); // [4, 2]: rows [0,1],[2,3],[4,5],[6,7]
174        let ids = Tensor::from_i32(vec![1], [1]).unwrap();
175        let out = transposed.gather_rows(&ids).unwrap();
176        assert_eq!(out.to_vec_f32().unwrap(), vec![2.0, 3.0]);
177    }
178
179    #[test]
180    fn gather_rows_rejects_an_out_of_range_id() {
181        let table = Tensor::from_f32(vec![1.0, 2.0], [1, 2]).unwrap();
182        let ids = Tensor::from_i32(vec![5], [1]).unwrap();
183        assert!(matches!(table.gather_rows(&ids), Err(Error::IndexOutOfBounds { .. })));
184    }
185
186    #[test]
187    fn gather_rows_requires_i32_indices() {
188        let table = Tensor::from_f32(vec![1.0, 2.0], [1, 2]).unwrap();
189        let bad_ids = Tensor::from_f32(vec![0.0], [1]).unwrap();
190        assert!(matches!(table.gather_rows(&bad_ids), Err(Error::DTypeMismatch { .. })));
191    }
192}
193
194#[cfg(test)]
195mod quantized_gather_tests {
196    use super::*;
197
198    /// Builds a `[rows, cols]` Q8_0 table whose blocks have distinct scales and
199    /// quants, so a row-addressing bug shifts values visibly instead of
200    /// returning something plausible.
201    fn q8_table(rows: usize, cols: usize) -> Vec<u8> {
202        let mut bytes = Vec::new();
203        for r in 0..rows {
204            for b in 0..(cols / 32) {
205                // scale = 1.0 for even blocks, 0.5 for odd (f16 bit patterns).
206                let scale: u16 = if (r + b) % 2 == 0 { 0x3C00 } else { 0x3800 };
207                bytes.extend_from_slice(&scale.to_le_bytes());
208                for t in 0..32 {
209                    bytes.push((((r * 7 + b * 3 + t) % 255) as i32 - 128) as i8 as u8);
210                }
211            }
212        }
213        bytes
214    }
215
216    /// The property that makes this safe to switch on: gathering from the
217    /// quantized table must equal gathering from the same table dequantized in
218    /// full. If it does not, embeddings silently change and the model degrades
219    /// in a way no shape check would catch.
220    #[test]
221    fn quantized_gather_matches_dequantize_then_gather() {
222        let (rows, cols) = (8usize, 64usize);
223        let bytes = q8_table(rows, cols);
224        let quantized = Tensor::from_quantized(DType::Q8_0, bytes, [rows, cols]).unwrap();
225        let full = quantized.to_dtype(DType::F32).unwrap();
226
227        // Deliberately out of order, repeated, and including the last row.
228        let ids = Tensor::from_i32(vec![3, 0, 7, 3, 5], [5]).unwrap();
229        let from_quant = quantized.gather_rows(&ids).unwrap().to_vec_f32().unwrap();
230        let from_f32 = full.gather_rows(&ids).unwrap().to_vec_f32().unwrap();
231        assert_eq!(from_quant.len(), 5 * cols);
232        assert_eq!(from_quant, from_f32, "quantized gather diverged from the f32 path");
233    }
234
235    #[test]
236    fn quantized_gather_rejects_an_out_of_range_id() {
237        let bytes = q8_table(4, 32);
238        let t = Tensor::from_quantized(DType::Q8_0, bytes, [4, 32]).unwrap();
239        let ids = Tensor::from_i32(vec![4], [1]).unwrap();
240        assert!(matches!(
241            t.gather_rows(&ids),
242            Err(Error::IndexOutOfBounds { .. })
243        ));
244    }
245
246    /// A row that is not a whole number of blocks cannot be decoded in
247    /// isolation, so the gather must refuse rather than read across into the
248    /// next row's data.
249    #[test]
250    fn a_row_that_straddles_a_block_boundary_is_refused() {
251        // cols = 48 is not a multiple of Q8_0's 32-element block.
252        let bytes = vec![0u8; 3 * 34];
253        let t = Tensor::from_quantized(DType::Q8_0, bytes, [2, 48]);
254        // Construction may already reject this; if it does not, the gather must.
255        if let Ok(t) = t {
256            let ids = Tensor::from_i32(vec![0], [1]).unwrap();
257            assert!(t.gather_rows(&ids).is_err(), "a straddling row must not be gathered");
258        }
259    }
260}