Skip to main content

voxtral_micro/tts/codec/
block.rs

1//! Codec decoder transformer block.
2//!
3//! Combines ALiBi attention, QK-norm, LayerScale, and SwiGLU into a single
4//! transformer layer for the codec decoder. Uses causal + sliding window masking.
5
6use anyhow::{Context, Result};
7use burn::module::{Param, ParamId};
8use burn::nn::Linear;
9use burn::tensor::activation::softmax;
10use burn::tensor::backend::Backend;
11use burn::tensor::Tensor;
12use safetensors::SafeTensors;
13
14use crate::models::layers::{RmsNorm, SwiGLU};
15use crate::models::weights::{linear_from_weights, load_tensor};
16use crate::tts::codec::alibi::ALiBi;
17use crate::tts::codec::layer_scale::LayerScale;
18use crate::tts::codec::qk_norm::QkNorm;
19
20/// Codec decoder attention with ALiBi, QK-norm, causal masking, and sliding window.
21///
22/// Unlike backbone attention, this uses:
23/// - MHA (all heads are query heads, no GQA)
24/// - ALiBi positional bias (no RoPE)
25/// - QK-norm (RMSNorm on Q and K before scoring)
26pub struct CodecAttention<B: Backend> {
27    wq: Linear<B>,
28    wk: Linear<B>,
29    wv: Linear<B>,
30    wo: Linear<B>,
31    qk_norm: QkNorm<B>,
32    alibi: ALiBi,
33    n_heads: usize,
34    head_dim: usize,
35    scale: f32,
36    sliding_window: usize,
37}
38
39impl<B: Backend> CodecAttention<B> {
40    /// Create codec attention from loaded components.
41    #[allow(clippy::too_many_arguments)]
42    pub fn new(
43        wq: Linear<B>,
44        wk: Linear<B>,
45        wv: Linear<B>,
46        wo: Linear<B>,
47        qk_norm: QkNorm<B>,
48        n_heads: usize,
49        head_dim: usize,
50        sliding_window: usize,
51    ) -> Self {
52        Self {
53            wq,
54            wk,
55            wv,
56            wo,
57            qk_norm,
58            alibi: ALiBi::new(n_heads),
59            n_heads,
60            head_dim,
61            scale: (head_dim as f32).powf(-0.5),
62            sliding_window,
63        }
64    }
65
66    /// Forward pass with ALiBi, QK-norm, causal mask, and sliding window.
67    ///
68    /// # Arguments
69    /// * `x` - Input tensor [batch, seq, dim]
70    ///
71    /// # Returns
72    /// Output tensor [batch, seq, dim]
73    pub fn forward(&self, x: Tensor<B, 3>) -> Tensor<B, 3> {
74        let [batch, seq_len, _dim] = x.dims();
75        let device = x.device();
76
77        // Project Q, K, V
78        let q = self.wq.forward(x.clone());
79        let k = self.wk.forward(x.clone());
80        let v = self.wv.forward(x);
81
82        // Reshape to [batch, seq, n_heads, head_dim]
83        let q = q.reshape([batch, seq_len, self.n_heads, self.head_dim]);
84        let k = k.reshape([batch, seq_len, self.n_heads, self.head_dim]);
85        let v = v.reshape([batch, seq_len, self.n_heads, self.head_dim]);
86
87        // Transpose to [batch, n_heads, seq, head_dim]
88        let q = q.swap_dims(1, 2);
89        let k = k.swap_dims(1, 2);
90        let v = v.swap_dims(1, 2);
91
92        // Apply QK-norm
93        let (q, k) = self.qk_norm.forward(q, k);
94
95        // Compute attention scores: Q @ K^T * scale
96        let scores = q.matmul(k.swap_dims(2, 3)) * self.scale;
97
98        // Add ALiBi positional bias
99        let alibi_bias = self.alibi.bias::<B>(seq_len, seq_len, &device);
100        let scores = scores + alibi_bias;
101
102        // Apply causal + sliding window mask
103        let scores = apply_causal_sliding_window_mask(scores, seq_len, self.sliding_window);
104
105        // Softmax
106        let attn = softmax(scores, 3);
107
108        // Apply attention: attn @ V
109        let out = attn.matmul(v);
110
111        // Transpose back and reshape: [batch, n_heads, seq, head_dim] -> [batch, seq, dim]
112        let out = out.swap_dims(1, 2);
113        let out = out.reshape([batch, seq_len, self.n_heads * self.head_dim]);
114
115        // Output projection
116        self.wo.forward(out)
117    }
118}
119
120/// Apply combined causal + sliding window mask to attention scores.
121///
122/// Masks positions where `j > i` (future) or `|i - j| > window` with `-inf`.
123fn apply_causal_sliding_window_mask<B: Backend>(
124    scores: Tensor<B, 4>,
125    seq_len: usize,
126    window: usize,
127) -> Tensor<B, 4> {
128    let device = scores.device();
129    let mut mask_data = vec![0.0f32; seq_len * seq_len];
130    for i in 0..seq_len {
131        for j in 0..seq_len {
132            if j > i || i.abs_diff(j) > window {
133                mask_data[i * seq_len + j] = f32::NEG_INFINITY;
134            }
135        }
136    }
137    let mask: Tensor<B, 1> = Tensor::from_floats(mask_data.as_slice(), &device);
138    let mask: Tensor<B, 2> = mask.reshape([seq_len, seq_len]);
139    let mask: Tensor<B, 4> = mask.unsqueeze_dim::<3>(0).unsqueeze_dim(0);
140    scores + mask
141}
142
143/// Codec decoder transformer layer.
144///
145/// Architecture:
146/// ```text
147/// x -> RmsNorm -> CodecAttention(ALiBi, QK-norm, causal, sliding_window) -> LayerScale -> + residual -> x'
148/// x' -> RmsNorm -> SwiGLU -> LayerScale -> + residual -> out
149/// ```
150///
151/// Parameters: 8 MHA heads, 1024 dim, head_dim 128, sliding window per block.
152pub struct CodecTransformerLayer<B: Backend> {
153    /// Pre-attention normalization.
154    attention_norm: RmsNorm<B>,
155    /// Codec attention with ALiBi + QK-norm.
156    attention: CodecAttention<B>,
157    /// Scales attention output before residual add.
158    attention_scale: LayerScale<B>,
159    /// Pre-FFN normalization.
160    ffn_norm: RmsNorm<B>,
161    /// SwiGLU MLP.
162    ffn: SwiGLU<B>,
163    /// Scales FFN output before residual add.
164    ffn_scale: LayerScale<B>,
165}
166
167impl<B: Backend> CodecTransformerLayer<B> {
168    /// Create a codec transformer layer from loaded components.
169    #[allow(clippy::too_many_arguments)]
170    pub fn new(
171        attention_norm: RmsNorm<B>,
172        attention: CodecAttention<B>,
173        attention_scale: LayerScale<B>,
174        ffn_norm: RmsNorm<B>,
175        ffn: SwiGLU<B>,
176        ffn_scale: LayerScale<B>,
177    ) -> Self {
178        Self {
179            attention_norm,
180            attention,
181            attention_scale,
182            ffn_norm,
183            ffn,
184            ffn_scale,
185        }
186    }
187
188    /// Load a codec transformer layer from SafeTensors.
189    ///
190    /// # Arguments
191    /// * `safetensors` - SafeTensors data
192    /// * `prefix` - Weight name prefix (e.g., `audio_tokenizer.decoder_blocks.0.layers.0`)
193    /// * `n_heads` - Number of attention heads
194    /// * `head_dim` - Per-head dimension
195    /// * `sliding_window` - Sliding window size for this layer
196    /// * `norm_eps` - RMSNorm epsilon
197    /// * `device` - Device for tensor allocation
198    #[allow(clippy::too_many_arguments)]
199    pub fn from_safetensors(
200        safetensors: &SafeTensors,
201        prefix: &str,
202        n_heads: usize,
203        head_dim: usize,
204        sliding_window: usize,
205        norm_eps: f64,
206        device: &B::Device,
207    ) -> Result<Self> {
208        // Attention weights
209        let wq_weight: Tensor<B, 2> = load_tensor(
210            safetensors,
211            &format!("{prefix}.attention.wq.weight"),
212            device,
213        )
214        .context("Loading wq")?;
215        let wk_weight: Tensor<B, 2> = load_tensor(
216            safetensors,
217            &format!("{prefix}.attention.wk.weight"),
218            device,
219        )
220        .context("Loading wk")?;
221        let wv_weight: Tensor<B, 2> = load_tensor(
222            safetensors,
223            &format!("{prefix}.attention.wv.weight"),
224            device,
225        )
226        .context("Loading wv")?;
227        let wo_weight: Tensor<B, 2> = load_tensor(
228            safetensors,
229            &format!("{prefix}.attention.wo.weight"),
230            device,
231        )
232        .context("Loading wo")?;
233
234        let wq = linear_from_weights(wq_weight, None);
235        let wk = linear_from_weights(wk_weight, None);
236        let wv = linear_from_weights(wv_weight, None);
237        let wo = linear_from_weights(wo_weight, None);
238
239        // QK-norm weights
240        let q_norm_weight: Tensor<B, 1> = load_tensor(
241            safetensors,
242            &format!("{prefix}.attention.q_norm.weight"),
243            device,
244        )
245        .context("Loading q_norm")?;
246        let k_norm_weight: Tensor<B, 1> = load_tensor(
247            safetensors,
248            &format!("{prefix}.attention.k_norm.weight"),
249            device,
250        )
251        .context("Loading k_norm")?;
252        let qk_norm = QkNorm::new(q_norm_weight, k_norm_weight, n_heads, head_dim);
253
254        let attention =
255            CodecAttention::new(wq, wk, wv, wo, qk_norm, n_heads, head_dim, sliding_window);
256
257        // LayerScale weights
258        let attn_scale_weight: Tensor<B, 1> =
259            load_tensor(safetensors, &format!("{prefix}.attention_scale"), device)
260                .context("Loading attention_scale")?;
261        let ffn_scale_weight: Tensor<B, 1> =
262            load_tensor(safetensors, &format!("{prefix}.ffn_scale"), device)
263                .context("Loading ffn_scale")?;
264        let attention_scale = LayerScale::new(attn_scale_weight);
265        let ffn_scale = LayerScale::new(ffn_scale_weight);
266
267        // Norms
268        let attn_norm_weight: Tensor<B, 1> = load_tensor(
269            safetensors,
270            &format!("{prefix}.attention_norm.weight"),
271            device,
272        )
273        .context("Loading attention_norm")?;
274        let ffn_norm_weight: Tensor<B, 1> =
275            load_tensor(safetensors, &format!("{prefix}.ffn_norm.weight"), device)
276                .context("Loading ffn_norm")?;
277
278        let attention_norm = RmsNorm {
279            weight: burn::nn::RmsNorm {
280                gamma: Param::initialized(ParamId::new(), attn_norm_weight),
281                epsilon: norm_eps,
282            },
283        };
284        let ffn_norm = RmsNorm {
285            weight: burn::nn::RmsNorm {
286                gamma: Param::initialized(ParamId::new(), ffn_norm_weight),
287                epsilon: norm_eps,
288            },
289        };
290
291        // SwiGLU FFN
292        let w1_weight: Tensor<B, 2> = load_tensor(
293            safetensors,
294            &format!("{prefix}.feed_forward.w1.weight"),
295            device,
296        )
297        .context("Loading ffn w1")?;
298        let w2_weight: Tensor<B, 2> = load_tensor(
299            safetensors,
300            &format!("{prefix}.feed_forward.w2.weight"),
301            device,
302        )
303        .context("Loading ffn w2")?;
304        let w3_weight: Tensor<B, 2> = load_tensor(
305            safetensors,
306            &format!("{prefix}.feed_forward.w3.weight"),
307            device,
308        )
309        .context("Loading ffn w3")?;
310
311        let w1 = linear_from_weights(w1_weight, None);
312        let w2 = linear_from_weights(w2_weight, None);
313        let w3 = linear_from_weights(w3_weight, None);
314        let ffn = SwiGLU::new(w1, w2, w3);
315
316        Ok(Self {
317            attention_norm,
318            attention,
319            attention_scale,
320            ffn_norm,
321            ffn,
322            ffn_scale,
323        })
324    }
325
326    /// Forward pass.
327    ///
328    /// # Arguments
329    /// * `x` - Input tensor [batch, seq, dim]
330    ///
331    /// # Returns
332    /// Output tensor [batch, seq, dim]
333    pub fn forward(&self, x: Tensor<B, 3>) -> Tensor<B, 3> {
334        // Attention with LayerScale + residual
335        let residual = x.clone();
336        let h = self.attention_norm.forward(x);
337        let h = self.attention.forward(h);
338        let h = self.attention_scale.forward(h);
339        let x = h + residual;
340
341        // FFN with LayerScale + residual
342        let residual = x.clone();
343        let h = self.ffn_norm.forward(x);
344        let h = self.ffn.forward(h);
345        let h = self.ffn_scale.forward(h);
346        h + residual
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use burn::backend::Wgpu;
354    use burn::nn::LinearConfig;
355    type TestBackend = Wgpu;
356
357    /// Helper to create a small codec transformer layer for testing.
358    fn make_test_layer(
359        dim: usize,
360        n_heads: usize,
361        head_dim: usize,
362        ffn_dim: usize,
363        sliding_window: usize,
364        device: &<TestBackend as Backend>::Device,
365    ) -> CodecTransformerLayer<TestBackend> {
366        let eps = 1e-5;
367
368        // Attention
369        let wq = LinearConfig::new(dim, dim).with_bias(false).init(device);
370        let wk = LinearConfig::new(dim, dim).with_bias(false).init(device);
371        let wv = LinearConfig::new(dim, dim).with_bias(false).init(device);
372        let wo = LinearConfig::new(dim, dim).with_bias(false).init(device);
373
374        let q_weight = Tensor::<TestBackend, 1>::ones([dim], device);
375        let k_weight = Tensor::<TestBackend, 1>::ones([dim], device);
376        let qk_norm = QkNorm::new(q_weight, k_weight, n_heads, head_dim);
377
378        let attention =
379            CodecAttention::new(wq, wk, wv, wo, qk_norm, n_heads, head_dim, sliding_window);
380
381        // LayerScale
382        let attn_scale = LayerScale::new(Tensor::ones([dim], device) * 0.01);
383        let ffn_scale_layer = LayerScale::new(Tensor::ones([dim], device) * 0.01);
384
385        // Norms
386        let attention_norm = RmsNorm {
387            weight: burn::nn::RmsNorm {
388                gamma: Param::initialized(
389                    ParamId::new(),
390                    Tensor::<TestBackend, 1>::ones([dim], device),
391                ),
392                epsilon: eps,
393            },
394        };
395        let ffn_norm = RmsNorm {
396            weight: burn::nn::RmsNorm {
397                gamma: Param::initialized(
398                    ParamId::new(),
399                    Tensor::<TestBackend, 1>::ones([dim], device),
400                ),
401                epsilon: eps,
402            },
403        };
404
405        // SwiGLU
406        use crate::models::layers::SwiGLUConfig;
407        let ffn = SwiGLUConfig::new(dim, ffn_dim)
408            .with_bias(false)
409            .init(device);
410
411        CodecTransformerLayer::new(
412            attention_norm,
413            attention,
414            attn_scale,
415            ffn_norm,
416            ffn,
417            ffn_scale_layer,
418        )
419    }
420
421    #[test]
422    fn test_codec_layer_output_shape() {
423        let device = Default::default();
424        let layer = make_test_layer(64, 4, 16, 256, 4, &device);
425
426        let x = Tensor::<TestBackend, 3>::zeros([1, 10, 64], &device);
427        let out = layer.forward(x);
428
429        assert_eq!(out.dims(), [1, 10, 64]);
430    }
431
432    #[test]
433    fn test_codec_layer_batch_shape() {
434        let device = Default::default();
435        let layer = make_test_layer(64, 4, 16, 256, 8, &device);
436
437        let x = Tensor::<TestBackend, 3>::zeros([2, 5, 64], &device);
438        let out = layer.forward(x);
439
440        assert_eq!(out.dims(), [2, 5, 64]);
441    }
442
443    #[test]
444    fn test_codec_layer_real_dims() {
445        // Codec defaults: 1024 dim, 8 heads, 128 head_dim, window 2
446        let device = Default::default();
447        let layer = make_test_layer(1024, 8, 128, 4096, 2, &device);
448
449        let x = Tensor::<TestBackend, 3>::zeros([1, 8, 1024], &device);
450        let out = layer.forward(x);
451
452        assert_eq!(out.dims(), [1, 8, 1024]);
453    }
454
455    #[test]
456    fn test_codec_layer_residual_connection() {
457        // With zero-initialized weights, output should equal input (residual passthrough)
458        // because attention and FFN produce near-zero outputs, and LayerScale * 0.01
459        // further suppresses them.
460        let device = Default::default();
461        let dim = 32;
462        let n_heads = 2;
463        let head_dim = 16;
464
465        let layer = make_test_layer(dim, n_heads, head_dim, 128, 4, &device);
466
467        let x = Tensor::<TestBackend, 3>::ones([1, 4, dim], &device) * 0.5;
468        let out = layer.forward(x.clone());
469
470        // Output should be close to input due to residual (small LayerScale)
471        let diff = (out - x).abs().max();
472        let diff_val = diff.to_data().as_slice::<f32>().unwrap()[0];
473
474        // The diff should be bounded — not exactly zero due to random init,
475        // but small due to LayerScale(0.01)
476        assert!(
477            diff_val < 5.0,
478            "Residual connection should keep output near input, got max diff {}",
479            diff_val
480        );
481    }
482
483    #[test]
484    fn test_codec_attention_shape() {
485        let device = Default::default();
486        let dim = 64;
487        let n_heads = 4;
488        let head_dim = 16;
489
490        let wq = LinearConfig::new(dim, dim).with_bias(false).init(&device);
491        let wk = LinearConfig::new(dim, dim).with_bias(false).init(&device);
492        let wv = LinearConfig::new(dim, dim).with_bias(false).init(&device);
493        let wo = LinearConfig::new(dim, dim).with_bias(false).init(&device);
494
495        let q_weight = Tensor::<TestBackend, 1>::ones([dim], &device);
496        let k_weight = Tensor::<TestBackend, 1>::ones([dim], &device);
497        let qk_norm = QkNorm::new(q_weight, k_weight, n_heads, head_dim);
498
499        let attn = CodecAttention::new(wq, wk, wv, wo, qk_norm, n_heads, head_dim, 4);
500
501        let x = Tensor::<TestBackend, 3>::zeros([2, 10, dim], &device);
502        let out = attn.forward(x);
503
504        assert_eq!(out.dims(), [2, 10, dim]);
505    }
506
507    #[test]
508    fn test_causal_sliding_window_mask() {
509        // Window=2, seq_len=5
510        // Position i can see positions max(0, i-2)..=i
511        let device: <TestBackend as Backend>::Device = Default::default();
512        let scores = Tensor::<TestBackend, 4>::zeros([1, 1, 5, 5], &device);
513        let masked = apply_causal_sliding_window_mask::<TestBackend>(scores, 5, 2);
514
515        let data = masked.to_data();
516        let vals = data.as_slice::<f32>().unwrap();
517
518        // Check that future positions are masked
519        for i in 0..5 {
520            for j in 0..5 {
521                let idx = i * 5 + j;
522                if j > i {
523                    // Future: should be -inf
524                    assert!(
525                        vals[idx].is_infinite() && vals[idx] < 0.0,
526                        "Position ({}, {}) should be masked (future), got {}",
527                        i,
528                        j,
529                        vals[idx]
530                    );
531                } else if i.abs_diff(j) > 2 {
532                    // Outside sliding window: should be -inf
533                    assert!(
534                        vals[idx].is_infinite() && vals[idx] < 0.0,
535                        "Position ({}, {}) should be masked (window), got {}",
536                        i,
537                        j,
538                        vals[idx]
539                    );
540                } else {
541                    // Visible: should be 0
542                    assert!(
543                        vals[idx] == 0.0,
544                        "Position ({}, {}) should be visible, got {}",
545                        i,
546                        j,
547                        vals[idx]
548                    );
549                }
550            }
551        }
552    }
553
554    #[test]
555    fn test_different_sliding_windows() {
556        // Verify different window sizes produce different masks
557        let device: <TestBackend as Backend>::Device = Default::default();
558        let seq_len = 8;
559
560        let scores_w2 = Tensor::<TestBackend, 4>::zeros([1, 1, seq_len, seq_len], &device);
561        let scores_w4 = Tensor::<TestBackend, 4>::zeros([1, 1, seq_len, seq_len], &device);
562
563        let masked_w2 = apply_causal_sliding_window_mask::<TestBackend>(scores_w2, seq_len, 2);
564        let masked_w4 = apply_causal_sliding_window_mask::<TestBackend>(scores_w4, seq_len, 4);
565
566        let w2_data = masked_w2.to_data();
567        let w4_data = masked_w4.to_data();
568        let w2_vals = w2_data.as_slice::<f32>().unwrap();
569        let w4_vals = w4_data.as_slice::<f32>().unwrap();
570
571        // Window=4 should have more visible positions than window=2
572        let w2_visible = w2_vals.iter().filter(|&&v| v == 0.0).count();
573        let w4_visible = w4_vals.iter().filter(|&&v| v == 0.0).count();
574        assert!(
575            w4_visible > w2_visible,
576            "Window=4 ({} visible) should have more visible positions than window=2 ({})",
577            w4_visible,
578            w2_visible
579        );
580    }
581}