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}