Skip to main content

rlx_ir/ops/
vq.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! Vector-quantization graph helpers — nearest-codebook assignment and
17//! residual VQ, composed from primitive ops (`mm`, `sum`, `argmin`/`argmax`,
18//! `gather`). Backend-agnostic: lowers entirely to existing kernels, so it
19//! runs on every backend without new op lowerings.
20//!
21//! These are the discrete-tokenizer front-ends used across the EEG foundation
22//! models in `exg` (NeuroRVQ, CodeBrain, EEGFormer, DeWave, BrainRVQ, …).
23//! Today each of those does the codebook search in a host-side nested loop;
24//! `vector_quantize` / `residual_vq` express the same math as one fused graph
25//! subtree so it runs on-device and participates in autodiff.
26//!
27//! ## Distance math
28//!
29//! For inputs `x[N, D]` and codebook `C[K, D]`, the squared-L2 distance to
30//! every code is `‖x‖² − 2·x·Cᵀ + ‖C‖²`. The `‖x‖²` term is constant across
31//! the `K` codes, so `argmin` over the codes ignores it — we compute only
32//! `‖C‖² − 2·x·Cᵀ` (shape `[N, K]`) and take the `argmin` along `K`.
33
34use crate::infer::GraphExt as _;
35use crate::{DType, Graph, NodeId};
36
37/// Distance metric for [`Graph::vector_quantize`].
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub enum VqMetric {
40    /// Squared Euclidean distance (standard VQ-VAE codebook).
41    L2,
42    /// Cosine similarity (row-L2-normalized dot product, e.g. NeuroRVQ).
43    Cosine,
44}
45
46impl Graph {
47    /// Nearest-codebook assignment.
48    ///
49    /// * `x` — inputs `[N, D]`.
50    /// * `codebook` — codes `[K, D]`.
51    ///
52    /// Returns `(indices, quantized)` where `indices` is `[N]` (f32-encoded
53    /// code ids, ready to feed `gather`) and `quantized` is `[N, D]`, the
54    /// selected code vectors. The straight-through estimator for training is
55    /// left to the caller (`x + stop_gradient(quantized − x)`), matching the
56    /// usual VQ-VAE recipe.
57    pub fn vector_quantize(
58        &mut self,
59        x: NodeId,
60        codebook: NodeId,
61        metric: VqMetric,
62    ) -> (NodeId, NodeId) {
63        let xs = self.shape(x).clone();
64        let cs = self.shape(codebook).clone();
65        assert_eq!(xs.rank(), 2, "vector_quantize: x must be rank-2 [N, D]");
66        assert_eq!(
67            cs.rank(),
68            2,
69            "vector_quantize: codebook must be rank-2 [K, D]"
70        );
71        let d = xs.dim(1).unwrap_static();
72        let k = cs.dim(0).unwrap_static();
73        assert_eq!(
74            cs.dim(1).unwrap_static(),
75            d,
76            "vector_quantize: x/codebook feature dim mismatch"
77        );
78
79        let idx = match metric {
80            VqMetric::L2 => {
81                // dist_proxy[N, K] = ‖C‖²(row) − 2·x·Cᵀ   (drop ‖x‖²)
82                //
83                // Formed as `add(‖C‖²_row, (−2)·x·Cᵀ)` rather than a `sub`: the
84                // `‖C‖²_row` operand broadcasts its size-1 row axis against the
85                // full `[N, K]` cross term, and `add` is commutative — so it is
86                // insensitive to a backend broadcasting the operands in either
87                // order (Metal's broadcast `Sub` reverses lhs/rhs).
88                let cb_t = self.transpose_(codebook, vec![1, 0]); // [D, K]
89                let cross = self.mm(x, cb_t); // [N, K]
90                let neg_two = self.constant(-2.0, DType::F32);
91                let neg_two_cross = self.mul(cross, neg_two);
92                let cb_sq = self.mul(codebook, codebook); // [K, D]
93                let cb_norm = self.sum(cb_sq, vec![1], false); // [K]
94                let cb_norm_row = self.reshape_(cb_norm, vec![1, k as i64]); // [1, K]
95                let dist = self.add(cb_norm_row, neg_two_cross); // [N, K]
96                let s = crate::shape::reduce_shape(self.shape(dist), &[1], false)
97                    .expect("argmin shape");
98                self.argmin(dist, 1, false, s) // [N]
99            }
100            VqMetric::Cosine => {
101                let xn = self.l2_normalize_rows(x);
102                let cn = self.l2_normalize_rows(codebook);
103                let cn_t = self.transpose_(cn, vec![1, 0]); // [D, K]
104                let sim = self.mm(xn, cn_t); // [N, K]
105                let s =
106                    crate::shape::reduce_shape(self.shape(sim), &[1], false).expect("argmax shape");
107                self.argmax(sim, 1, false, s) // [N]
108            }
109        };
110
111        let quantized = self.gather_(codebook, idx, 0); // [N, D]
112        (idx, quantized)
113    }
114
115    /// Residual (multi-stage) vector quantization.
116    ///
117    /// Quantizes `x` against `codebooks[0]`, subtracts the chosen code, then
118    /// quantizes the residual against `codebooks[1]`, and so on. Returns the
119    /// per-level `indices` (one `[N]` tensor per stage) and the summed
120    /// reconstruction `quantized[N, D]` (`Σ_level code_level`). This is the
121    /// RVQ tokenizer in NeuroRVQ / BrainRVQ.
122    pub fn residual_vq(
123        &mut self,
124        x: NodeId,
125        codebooks: &[NodeId],
126        metric: VqMetric,
127    ) -> (Vec<NodeId>, NodeId) {
128        assert!(!codebooks.is_empty(), "residual_vq: need ≥1 codebook");
129        let mut indices = Vec::with_capacity(codebooks.len());
130        let (idx0, mut recon) = self.vector_quantize(x, codebooks[0], metric);
131        indices.push(idx0);
132        let mut residual = self.sub(x, recon);
133        for &cb in &codebooks[1..] {
134            let (idx, q) = self.vector_quantize(residual, cb, metric);
135            indices.push(idx);
136            recon = self.add(recon, q);
137            residual = self.sub(residual, q);
138        }
139        (indices, recon)
140    }
141
142    /// Row-wise L2 normalization: `x / sqrt(Σ x² + eps)` over the last axis.
143    fn l2_normalize_rows(&mut self, x: NodeId) -> NodeId {
144        let sq = self.mul(x, x);
145        let rank = self.shape(x).rank();
146        let sum = self.sum(sq, vec![rank - 1], true); // [.., 1]
147        let eps = self.constant(1e-12, DType::F32);
148        let sum_eps = self.add(sum, eps);
149        let norm = self.sqrt(sum_eps);
150        self.div(x, norm)
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use crate::{Op, Shape};
158
159    fn const_f32(g: &mut Graph, xs: &[f32], shape: &[usize]) -> NodeId {
160        let mut bytes = Vec::with_capacity(xs.len() * 4);
161        for x in xs {
162            bytes.extend_from_slice(&x.to_le_bytes());
163        }
164        g.add_node(
165            Op::Constant { data: bytes },
166            vec![],
167            Shape::new(shape, DType::F32),
168        )
169    }
170
171    fn dims(g: &Graph, id: NodeId) -> Vec<usize> {
172        g.shape(id)
173            .dims()
174            .iter()
175            .map(|d| d.unwrap_static())
176            .collect()
177    }
178
179    #[test]
180    fn vq_shapes_l2() {
181        let mut g = Graph::new("vq");
182        let x = const_f32(&mut g, &[0.0; 3 * 4], &[3, 4]);
183        let cb = const_f32(&mut g, &[0.0; 5 * 4], &[5, 4]);
184        let (idx, q) = g.vector_quantize(x, cb, VqMetric::L2);
185        assert_eq!(dims(&g, idx), vec![3]);
186        assert_eq!(dims(&g, q), vec![3, 4]);
187    }
188
189    #[test]
190    fn vq_shapes_cosine() {
191        let mut g = Graph::new("vq_cos");
192        let x = const_f32(&mut g, &[0.0; 2 * 6], &[2, 6]);
193        let cb = const_f32(&mut g, &[0.0; 8 * 6], &[8, 6]);
194        let (idx, q) = g.vector_quantize(x, cb, VqMetric::Cosine);
195        assert_eq!(dims(&g, idx), vec![2]);
196        assert_eq!(dims(&g, q), vec![2, 6]);
197    }
198
199    #[test]
200    fn rvq_shapes() {
201        let mut g = Graph::new("rvq");
202        let x = const_f32(&mut g, &[0.0; 4 * 4], &[4, 4]);
203        let cbs: Vec<_> = (0..3)
204            .map(|_| const_f32(&mut g, &[0.0; 16 * 4], &[16, 4]))
205            .collect();
206        let (indices, recon) = g.residual_vq(x, &cbs, VqMetric::L2);
207        assert_eq!(indices.len(), 3);
208        assert_eq!(dims(&g, recon), vec![4, 4]);
209    }
210}