Skip to main content

rlx_ir/ops/
vq.rs

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