diff --git a/ARCHITECTURE_V4.md b/ARCHITECTURE_V4.md index 0f488fe..3da76e1 100644 --- a/ARCHITECTURE_V4.md +++ b/ARCHITECTURE_V4.md @@ -149,6 +149,11 @@ another. **Crate:** `aarambh-studio-nn` (`mla.rs`) | **Depends on:** v1 §6.3 (GQA/RoPE), v2 §21 (YaRN/NTK), v3 §29 (`HybridAttentionSchedule`) +> **Status: Implemented in v4.0.0-alpha.1 (Phase 41).** `MlaAttention` and +> `MlaCache` ship in `crates/aarambh-studio-nn/src/mla.rs`; `AttentionKind::LatentMLA` +> and `MlaConfig` extend the schedule; the partial-checkpoint retrofit and +> `--kv-cache-report` are wired through. See `docs/phase41_mla.md` for usage. + ### The Problem v3 gave the model two ways to reduce the cost of a growing KV cache: diff --git a/CHANGELOG.md b/CHANGELOG.md index 6465f98..2ad7fec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,42 @@ > From first principles. From zero. From Rust. +## [4.0.0-alpha.1] - 2026-07-31 + +### Added + +- **Phase 41 — Multi-Head Latent Attention (MLA):** A third attention kind + (`AttentionKind::LatentMLA`) joins Full and Gated DeltaNet in the + `HybridAttentionSchedule`, completing the attention family v3 began (linear, + sparse, latent-compressed). MLA layers cache a single low-rank latent vector + (`c_kv`, width `latent_dim`) plus a small dedicated rotary key slice + (`rope_head_dim`) per token, reconstructing per-head keys and values at + attention time through trained up-projection weights that are never cached. + - New `aarambh-studio-nn::mla` module (`MlaAttention`, `MlaCache`) with + decoupled RoPE (nope half from the latent, rope half separately cached), + inference/training/batched-decode/capture paths, and QAT-wrapped + projections (`QatTarget::Mla`). + - `HybridAttentionSchedule` extended with `mla_layers: Vec` and + `mla: Option`; `mla_layers` takes precedence over the + `full_attention_every_n` rule and the DSA override. A schedule with an + empty `mla_layers` reproduces v3.0.0 exactly. + - New `MlaConfig` (`latent_dim`, `nope_head_dim`, `rope_head_dim`, `n_heads`, + `value_head_dim`) with dimension derivation and validation. + - Model integration: per-layer MLA build, `HybridKvCache::Mla` allocation, + named-tensor export (`blocks.{i}.mla.*`), and weight lookup. + - Partial-checkpoint retrofit extended: `.mla.` tensors are freshly + initialised alongside `.deltanet.` and `.dsa.` while every shared tensor + loads bit-exactly (`RetrofitLoadReport.initialized_mla_tensors`). + - `aarambh-studio eval --kv-cache-report` prints per-layer bytes/token by + attention kind (no checkpoint required). + - New configs: `configs/mla_smoke.toml`, `configs/medium_hybrid_mla.toml`, + `configs/large_hybrid_mla.toml`; new scripts + `scripts/phase41_prepare_mla_retrofit.sh`, `scripts/phase41_smoke.sh`; + new doc `docs/phase41_mla.md`. + - For the Medium hybrid MLA config, MLA per-token cache = 528 elements vs the + 1024-element GQA baseline — a ~1.94× reduction on retrofitted layers at + long context. + ## [3.0.0] - 2026-07-25 ### Added diff --git a/Cargo.lock b/Cargo.lock index 44cab5b..6d1fdbb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "aarambh-studio" -version = "3.0.0" +version = "4.0.0-alpha.1" dependencies = [ "aarambh-studio-agent", "aarambh-studio-core", @@ -35,7 +35,7 @@ dependencies = [ [[package]] name = "aarambh-studio-agent" -version = "3.0.0" +version = "4.0.0-alpha.1" dependencies = [ "aarambh-studio-core", "aarambh-studio-inference", @@ -46,7 +46,7 @@ dependencies = [ [[package]] name = "aarambh-studio-core" -version = "3.0.0" +version = "4.0.0-alpha.1" dependencies = [ "candle-core", "serde", @@ -56,7 +56,7 @@ dependencies = [ [[package]] name = "aarambh-studio-data" -version = "3.0.0" +version = "4.0.0-alpha.1" dependencies = [ "aarambh-studio-core", "candle-core", @@ -67,7 +67,7 @@ dependencies = [ [[package]] name = "aarambh-studio-distill" -version = "3.0.0" +version = "4.0.0-alpha.1" dependencies = [ "aarambh-studio-core", "aarambh-studio-inference", @@ -84,7 +84,7 @@ dependencies = [ [[package]] name = "aarambh-studio-eval" -version = "3.0.0" +version = "4.0.0-alpha.1" dependencies = [ "aarambh-studio-agent", "aarambh-studio-core", @@ -105,7 +105,7 @@ dependencies = [ [[package]] name = "aarambh-studio-finetune" -version = "3.0.0" +version = "4.0.0-alpha.1" dependencies = [ "aarambh-studio-core", "aarambh-studio-kernel", @@ -125,7 +125,7 @@ dependencies = [ [[package]] name = "aarambh-studio-inference" -version = "3.0.0" +version = "4.0.0-alpha.1" dependencies = [ "aarambh-studio-core", "aarambh-studio-model", @@ -141,7 +141,7 @@ dependencies = [ [[package]] name = "aarambh-studio-kernel" -version = "3.0.0" +version = "4.0.0-alpha.1" dependencies = [ "aarambh-studio-core", "candle-core", @@ -155,7 +155,7 @@ dependencies = [ [[package]] name = "aarambh-studio-model" -version = "3.0.0" +version = "4.0.0-alpha.1" dependencies = [ "aarambh-studio-core", "aarambh-studio-nn", @@ -166,7 +166,7 @@ dependencies = [ [[package]] name = "aarambh-studio-nn" -version = "3.0.0" +version = "4.0.0-alpha.1" dependencies = [ "aarambh-studio-core", "aarambh-studio-kernel", @@ -177,7 +177,7 @@ dependencies = [ [[package]] name = "aarambh-studio-quant" -version = "3.0.0" +version = "4.0.0-alpha.1" dependencies = [ "aarambh-studio-core", "candle-core", @@ -188,7 +188,7 @@ dependencies = [ [[package]] name = "aarambh-studio-safety" -version = "3.0.0" +version = "4.0.0-alpha.1" dependencies = [ "aarambh-studio-core", "aarambh-studio-inference", @@ -199,7 +199,7 @@ dependencies = [ [[package]] name = "aarambh-studio-selflearn" -version = "3.0.0" +version = "4.0.0-alpha.1" dependencies = [ "aarambh-studio-core", "aarambh-studio-eval", @@ -220,7 +220,7 @@ dependencies = [ [[package]] name = "aarambh-studio-serve" -version = "3.0.0" +version = "4.0.0-alpha.1" dependencies = [ "aarambh-studio-core", "aarambh-studio-inference", @@ -244,7 +244,7 @@ dependencies = [ [[package]] name = "aarambh-studio-tokenizer" -version = "3.0.0" +version = "4.0.0-alpha.1" dependencies = [ "aarambh-studio-core", "serde", @@ -254,7 +254,7 @@ dependencies = [ [[package]] name = "aarambh-studio-train" -version = "3.0.0" +version = "4.0.0-alpha.1" dependencies = [ "aarambh-studio-core", "aarambh-studio-data", @@ -271,7 +271,7 @@ dependencies = [ [[package]] name = "aarambh-studio-vision" -version = "3.0.0" +version = "4.0.0-alpha.1" dependencies = [ "aarambh-studio-core", "candle-core", @@ -287,7 +287,7 @@ dependencies = [ [[package]] name = "aarambh-studio-weights" -version = "3.0.0" +version = "4.0.0-alpha.1" dependencies = [ "aarambh-studio-core", "aarambh-studio-model", diff --git a/Cargo.toml b/Cargo.toml index ac6e5cc..ac9babe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ members = [ resolver = "2" [workspace.package] -version = "3.0.0" +version = "4.0.0-alpha.1" edition = "2024" rust-version = "1.89" description = "From first principles. From zero. From Rust." diff --git a/README.md b/README.md index 661cf7d..5efefc6 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,9 @@ with hybrid Gated DeltaNet, DeepSeek Sparse Attention, fine-grained MoE with shared experts, Multi-Token Prediction (MTP), on-policy distillation, native quantization-aware training, native video/document input, bounded long-horizon tool-use chains, persistent forgetting diagnostics, and -Max thinking mode (16,384-token budget). +Max thinking mode (16,384-token budget). **v4.0.0-alpha.1** begins the v4 arc +with Multi-Head Latent Attention (Phase 41) — a third attention kind that +compresses the KV cache into a single low-rank latent per token. > [!IMPORTANT] > This is a source and engineering project. It does not publish crates to @@ -30,7 +32,7 @@ Max thinking mode (16,384-token budget). | Area | Capabilities | |---|---| | Model | RMSNorm, RoPE, GQA, SwiGLU, KV cache, tied embeddings, Tiny to Large configs | -| Efficient architecture | YaRN/NTK/linear RoPE scaling, Gated DeltaNet, learned block-sparse DSA, fine-grained MoE, MTP | +| Efficient architecture | YaRN/NTK/linear RoPE scaling, Gated DeltaNet, learned block-sparse DSA, Multi-Head Latent Attention (MLA), fine-grained MoE, MTP | | Training | BPE data pipeline, AdamW, cosine schedule, gradient accumulation/clipping, checkpoint resume, BF16 CUDA, single-node multi-GPU, on-policy distillation, native INT4/INT8 QAT | | Fine-tuning | SFT, LoRA, QLoRA, DoRA, QDoRA, VLM adapters, GRPO, DPO, QDPO, tool-call tuning | | Inference | Greedy/sampled decoding, streaming, thinking budgets, external or one-checkpoint MTP speculation, tool grammar, caller-executed chains | @@ -119,7 +121,9 @@ See the phase-specific docs for full walkthroughs with smoke fixtures: | Document understanding | [docs/phase36_document.md](docs/phase36_document.md) | | OpenAI-compatible server | [docs/inference-server.md](docs/inference-server.md) | | Evaluation & forgetting diagnostics | [docs/phase38_forgetting.md](docs/phase38_forgetting.md) | +| Multi-Head Latent Attention (MLA) | [docs/phase41_mla.md](docs/phase41_mla.md) | | Quantization (GPTQ, QAT, GGUF) | [docs/phase34_qat.md](docs/phase34_qat.md) | +| MLA hybrid attention & KV-cache report | `aarambh-studio eval --kv-cache-report` + [docs/phase41_mla.md](docs/phase41_mla.md) | | Fine-tuning (SFT, adapters, GRPO, DPO) | `aarambh-studio finetune --help` | | Self-learning | [SELF_LEARNING_V3.md](SELF_LEARNING_V3.md) | diff --git a/ROADMAP_V4.md b/ROADMAP_V4.md index 455309f..1c34d39 100644 --- a/ROADMAP_V4.md +++ b/ROADMAP_V4.md @@ -186,6 +186,14 @@ beyond what each phase's Dependency Policy note allows. **Duration:** 10–14 days | **Hardware:** Kaggle (free quota) +> **Status: Implemented in v4.0.0-alpha.1.** The `mla.rs` module, `MlaConfig`, +> the three-way `HybridAttentionSchedule`, the partial-checkpoint retrofit path, +> `--kv-cache-report`, the smoke/retrofit scripts, and the full test suite +> (reconstruction tolerance, decoupled-RoPE split, cache-size, partial-load, +> backward-reachability) are all in place. See `docs/phase41_mla.md` and +> `CHANGELOG.md` §4.0.0-alpha.1. The checkbox list below is the original plan, +> preserved for traceability. + ### Goal A third attention kind — latent KV compression — addable to the `HybridAttentionSchedule` v3 §29 introduced, so a model can now mix diff --git a/SELF_LEARNING_V4.md b/SELF_LEARNING_V4.md index e1e626c..c3b98b1 100644 --- a/SELF_LEARNING_V4.md +++ b/SELF_LEARNING_V4.md @@ -106,6 +106,16 @@ their own pass: The short version: **nothing changes, and that is the point.** +> **Status: Verified for v4.0.0-alpha.1 (Phase 41).** The +> `mla_training_backward_reaches_mla_parameters` test confirms gradients reach +> the MLA down-projection (`kv_a_proj`), latent norm, value up-projection +> (`up_v`), and output projection (`o_proj`) — the §42 reachability argument. +> MLA's Q/K gradient behaviour on the CPU candle-fallback attention path +> matches the existing GQA path (full Q/K gradients flow under the CUDA/flash +> path used in real training); MLA is wired into the identical attention path, +> so self-learning's gradient orthogonalisation reaches MLA weights +> consistently with every other attention kind. + Online GRPO's math (`SELF_LEARNING.md` §5) operates on log-probabilities of generated tokens under the current policy. It has no dependency on *how* those log-probabilities were computed internally — whether a diff --git a/aarambh-studio/src/cmd/eval.rs b/aarambh-studio/src/cmd/eval.rs index 6917400..8a280da 100644 --- a/aarambh-studio/src/cmd/eval.rs +++ b/aarambh-studio/src/cmd/eval.rs @@ -1,13 +1,14 @@ use std::fs; use std::path::{Path, PathBuf}; -use aarambh_studio_core::TokenizerLike; +use aarambh_studio_core::{AttentionKind, TokenizerLike}; use aarambh_studio_eval::{ DEFAULT_SIGNIFICANCE_THRESHOLD, EvalConfig, EvalContext, ForgettingReport, ForgettingStore, ProbeManifest, QatRobustnessReport, Scorecard, ScorecardComparison, run_all, run_capability_probes, tokenizer_fingerprint, }; use aarambh_studio_inference::ThinkingMode; +use aarambh_studio_model::{KvCacheLayerReport, kv_cache_report}; use aarambh_studio_quant::GgufFormat; use aarambh_studio_tokenizer::BpeTokenizer; use aarambh_studio_train::TrainingRunConfig; @@ -62,6 +63,9 @@ pub struct EvalArgs { pub forgetting_jsonl: Option, #[arg(long, requires = "forgetting_manifest")] pub require_all_probes: bool, + /// Print per-layer KV-cache bytes/token by attention kind and exit (v4 Phase 41). + #[arg(long)] + pub kv_cache_report: bool, } #[derive(Debug, Deserialize)] @@ -84,6 +88,9 @@ pub fn run(args: EvalArgs) -> anyhow::Result<()> { let run_config = TrainingRunConfig::from_toml(config_path)?; let run_device = run_config.device()?; let dtype = run_config.dtype_for_device(&run_device)?.to_candle(); + if args.kv_cache_report { + return run_kv_cache_report(&run_config.model, dtype); + } let device = run_device.to_candle()?; let tokenizer_path = tokenizer_path(&args, &run_config); let model_path = match args.model.clone() { @@ -441,3 +448,73 @@ fn write_outputs( } Ok(()) } + +fn dtype_bytes(dtype: candle_core::DType) -> usize { + use candle_core::DType; + match dtype { + DType::F64 => 8, + DType::F32 => 4, + DType::F16 | DType::BF16 => 2, + DType::U8 => 1, + _ => 4, + } +} + +fn kind_name(kind: AttentionKind) -> &'static str { + match kind { + AttentionKind::Full => "full", + AttentionKind::Sparse => "sparse_dsa", + AttentionKind::GatedDeltaNet => "gated_deltanet", + AttentionKind::LatentMLA => "latent_mla", + } +} + +/// Print a per-layer KV-cache bytes/token breakdown and exit (Phase 41). +fn run_kv_cache_report( + cfg: &aarambh_studio_core::ModelConfig, + dtype: candle_core::DType, +) -> anyhow::Result<()> { + let bytes = dtype_bytes(dtype); + let report = kv_cache_report(cfg, bytes); + let full_baseline: usize = 2 * cfg.n_kv_heads * cfg.head_dim() * bytes; + let total: usize = report.iter().map(|r| r.bytes_per_token).sum(); + + println!( + "KV-cache bytes/token (dtype={:?}, {} bytes/element, {} layers)", + dtype, + bytes, + report.len() + ); + println!("{:<6} {:<16} {:>12} note", "layer", "kind", "bytes/tok"); + for KvCacheLayerReport { + layer, + kind, + bytes_per_token, + note, + } in &report + { + println!( + "{:<6} {:<16} {:>12} {}", + layer, + kind_name(*kind), + bytes_per_token, + note + ); + } + println!("-------------------------------------------------------------"); + println!("total bytes/token across all layers: {total}"); + println!( + "all-full baseline ({} layers): {}", + cfg.n_layers, + full_baseline * cfg.n_layers + ); + if full_baseline * cfg.n_layers > 0 { + let ratio = total as f64 / (full_baseline * cfg.n_layers) as f64; + println!( + "hybrid/all-full ratio: {:.3} ({:.1}% of all-full cache)", + ratio, + 100.0 * ratio + ); + } + Ok(()) +} diff --git a/configs/large_hybrid_mla.toml b/configs/large_hybrid_mla.toml new file mode 100644 index 0000000..85674eb --- /dev/null +++ b/configs/large_hybrid_mla.toml @@ -0,0 +1,71 @@ +dataset_path = "data/wikitext-103-raw/wiki.train.raw" +tokenizer_path = "checkpoints/wikitext103_large/tokenizer.json" +vocab_size = 32000 +validation_split = 0.01 +shuffle = true +resume = false +retrofit_from = "checkpoints/wikitext103_large_hybrid/model.safetensors" +retrofit_lr_scale = 0.1 +device = "cuda:0" +dtype = "bf16" + +[model] +vocab_size = 32000 +hidden_dim = 2048 +ffn_dim = 6656 +n_layers = 24 +n_heads = 32 +n_kv_heads = 8 +max_seq_len = 16384 +rope_theta = 500000.0 +norm_eps = 0.00001 +tie_embeddings = true + +[model.rope_scaling] +method = "yarn" +factor = 4.0 +original_max_seq_len = 4096 +beta_fast = 32.0 +beta_slow = 1.0 +attention_factor = 1.0 + +# Phase 41 three-way hybrid: Full + Gated DeltaNet + LatentMLA at Large scale. +# full_attention_every_n = 4 -> layers 0,4,8,12,16,20 stay Full +# mla_layers = [2,6,10,14,18,22] -> six Gated DeltaNet slots become LatentMLA +[model.attention_schedule] +full_attention_every_n = 4 +mla_layers = [2, 6, 10, 14, 18, 22] + +[model.attention_schedule.gated_deltanet] +n_heads = 16 +key_head_dim = 128 +value_head_dim = 256 +conv_kernel_size = 4 +chunk_size = 64 + +[model.attention_schedule.mla] +latent_dim = 768 +rope_head_dim = 16 +# Derived: n_heads=32, nope_head_dim=64-16=48, value_head_dim=48. +# MLA per-token cache = 768 + 16 = 784 elements +# vs full-attention per-token cache = 2 * 8 * 64 = 1024 elements (~1.30x smaller). +# A larger latent_dim preserves more per-head expressiveness at Large scale. + +[train] +lr = 0.0001 +batch_size = 1 +grad_accum_steps = 32 +max_epochs = 1 +max_steps = 30000 +warmup_steps = 1000 +min_lr_ratio = 0.1 +weight_decay = 0.1 +beta1 = 0.9 +beta2 = 0.95 +epsilon = 0.00000001 +clip_grad_norm = 1.0 +save_every_n_steps = 2500 +log_every_n_steps = 10 +eval_steps = 500 +seed = 42 +checkpoint_dir = "checkpoints/wikitext103_large_hybrid_mla" diff --git a/configs/medium_hybrid_mla.toml b/configs/medium_hybrid_mla.toml new file mode 100644 index 0000000..96c0741 --- /dev/null +++ b/configs/medium_hybrid_mla.toml @@ -0,0 +1,72 @@ +dataset_path = "data/wikitext-103-raw/wiki.train.raw" +tokenizer_path = "checkpoints/wikitext103_medium/tokenizer.json" +vocab_size = 32000 +validation_split = 0.01 +shuffle = true +resume = false +retrofit_from = "checkpoints/wikitext103_medium_hybrid/model.safetensors" +retrofit_lr_scale = 0.1 +device = "cuda:0" +dtype = "bf16" + +[model] +vocab_size = 32000 +hidden_dim = 1024 +ffn_dim = 3392 +n_layers = 24 +n_heads = 16 +n_kv_heads = 8 +max_seq_len = 16384 +rope_theta = 500000.0 +norm_eps = 0.00001 +tie_embeddings = true + +[model.rope_scaling] +method = "yarn" +factor = 8.0 +original_max_seq_len = 2048 +beta_fast = 32.0 +beta_slow = 1.0 +attention_factor = 1.0 + +# Phase 41 three-way hybrid: Full + Gated DeltaNet + LatentMLA. +# full_attention_every_n = 4 -> layers 0,4,8,12,16,20 stay Full +# mla_layers = [2,6,10,14,18,22] -> six Gated DeltaNet slots become LatentMLA +# remaining layers (1,3,5,7,9,11,13,15,17,19,21,23) stay Gated DeltaNet +# No dsa_config, so the Full layers remain dense full attention (not DSA). +[model.attention_schedule] +full_attention_every_n = 4 +mla_layers = [2, 6, 10, 14, 18, 22] + +[model.attention_schedule.gated_deltanet] +n_heads = 8 +key_head_dim = 96 +value_head_dim = 192 +conv_kernel_size = 4 +chunk_size = 64 + +[model.attention_schedule.mla] +latent_dim = 512 +rope_head_dim = 16 +# Derived: n_heads=16, nope_head_dim=64-16=48, value_head_dim=48. +# MLA per-token cache = 512 + 16 = 528 elements +# vs full-attention per-token cache = 2 * 8 * 64 = 1024 elements (~1.94x smaller). + +[train] +lr = 0.0002 +batch_size = 2 +grad_accum_steps = 16 +max_epochs = 1 +max_steps = 30000 +warmup_steps = 1000 +min_lr_ratio = 0.1 +weight_decay = 0.1 +beta1 = 0.9 +beta2 = 0.95 +epsilon = 0.00000001 +clip_grad_norm = 1.0 +save_every_n_steps = 2500 +log_every_n_steps = 10 +eval_steps = 500 +seed = 42 +checkpoint_dir = "checkpoints/wikitext103_medium_hybrid_mla" diff --git a/configs/mla_smoke.toml b/configs/mla_smoke.toml new file mode 100644 index 0000000..5974310 --- /dev/null +++ b/configs/mla_smoke.toml @@ -0,0 +1,64 @@ +dataset_path = "data/tiny_shakespeare.txt" +tokenizer_save_path = "checkpoints/mla_smoke/tokenizer.json" +vocab_size = 8000 +validation_split = 0.01 +shuffle = true +resume = false +device = "cpu" +dtype = "f32" + +[model] +vocab_size = 8000 +hidden_dim = 128 +ffn_dim = 256 +n_layers = 2 +n_heads = 2 +n_kv_heads = 1 +max_seq_len = 64 +rope_theta = 10000.0 +norm_eps = 0.00001 +tie_embeddings = true + +# Phase 41 hybrid schedule: layer 0 = LatentMLA, layer 1 = Gated DeltaNet. +# `full_attention_every_n = 2` would mark layer 0 as Full, but `mla_layers = [0]` +# upgrades it to LatentMLA (MLA takes precedence over the every-n rule and the +# DSA override). A zero-MLA schedule reproduces v3.0.0 exactly. +[model.attention_schedule] +full_attention_every_n = 2 +mla_layers = [0] + +[model.attention_schedule.gated_deltanet] +n_heads = 1 +key_head_dim = 32 +value_head_dim = 64 +conv_kernel_size = 4 +chunk_size = 16 + +[model.attention_schedule.mla] +latent_dim = 64 +rope_head_dim = 16 +# nope_head_dim, value_head_dim, n_heads default to 0 and are derived: +# n_heads = model.n_heads = 2 +# nope_head_dim = host_head_dim - rope_head_dim = 64 - 16 = 48 +# value_head_dim = nope_head_dim = 48 +# Per-token MLA cache = latent_dim + rope_head_dim = 80 elements +# vs GQA baseline = 2 * n_kv_heads * head_dim = 2 * 1 * 64 = 128 elements. + +[train] +lr = 0.001 +batch_size = 1 +grad_accum_steps = 1 +max_epochs = 1 +max_steps = 2 +warmup_steps = 0 +min_lr_ratio = 1.0 +weight_decay = 0.0 +beta1 = 0.9 +beta2 = 0.95 +epsilon = 0.00000001 +clip_grad_norm = 1.0 +save_every_n_steps = 0 +log_every_n_steps = 1 +eval_steps = 0 +seed = 42 +checkpoint_dir = "checkpoints/mla_smoke" diff --git a/crates/aarambh-studio-core/src/config.rs b/crates/aarambh-studio-core/src/config.rs index 75d4356..caa23ed 100644 --- a/crates/aarambh-studio-core/src/config.rs +++ b/crates/aarambh-studio-core/src/config.rs @@ -221,6 +221,8 @@ pub enum AttentionKind { Sparse, /// Fixed-state Gated DeltaNet linear attention. GatedDeltaNet, + /// Multi-Head Latent Attention with compressed-latent KV cache (v4 Phase 41). + LatentMLA, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -330,6 +332,8 @@ pub enum QatTarget { DsaIndexer, /// Multi-token prediction refinement projections. Mtp, + /// Multi-Head Latent Attention down/up and rope projections (v4 Phase 41). + Mla, /// The language-model output projection. LmHead, } @@ -358,6 +362,7 @@ impl Default for QatConfig { QatTarget::DeltaNet, QatTarget::DsaIndexer, QatTarget::Mtp, + QatTarget::Mla, ] .into_iter() .collect(), @@ -515,12 +520,126 @@ impl GatedDeltaNetConfig { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default)] -/// Per-layer schedule for hybrid full and Gated DeltaNet attention. +/// Multi-Head Latent Attention settings (v4 Phase 41). +/// +/// MLA compresses the per-token KV cache into a single low-rank latent vector +/// (`latent_dim`), reconstructing per-head keys and values at attention time +/// through small up-projection weights that are trained but never cached. A +/// small dedicated rotary slice (`rope_head_dim`) is cached alongside the +/// latent so position can be re-introduced without rotating the compressed +/// latent. See `ARCHITECTURE_V4.md` §55 for the full mechanism. +pub struct MlaConfig { + /// Width of the compressed latent cached per token (the down-projection output). + pub latent_dim: usize, + /// Per-head width of the non-rotary ("nope") query/key slice. + /// + /// Zero derives `host_head_dim - rope_head_dim` against the host transformer. + pub nope_head_dim: usize, + /// Per-head width of the rotary-encoded query/key slice. Must be even. + pub rope_head_dim: usize, + /// Number of MLA query heads. Zero derives the host transformer head count. + pub n_heads: usize, + /// Per-head width of the reconstructed value. Zero derives `nope_head_dim`. + pub value_head_dim: usize, +} + +impl Default for MlaConfig { + fn default() -> Self { + Self { + latent_dim: 512, + nope_head_dim: 0, + rope_head_dim: 16, + n_heads: 0, + value_head_dim: 0, + } + } +} + +impl MlaConfig { + /// Resolve automatic dimensions against a transformer model. + pub fn resolve(&self, hidden_dim: usize, transformer_heads: usize) -> Result { + let n_heads = if self.n_heads == 0 { + transformer_heads + } else { + self.n_heads + }; + let rope_head_dim = self.rope_head_dim; + let nope_head_dim = if self.nope_head_dim == 0 { + let host_head_dim = hidden_dim / transformer_heads; + host_head_dim.saturating_sub(rope_head_dim).max(8) + } else { + self.nope_head_dim + }; + let value_head_dim = if self.value_head_dim == 0 { + nope_head_dim + } else { + self.value_head_dim + }; + let resolved = Self { + latent_dim: self.latent_dim, + nope_head_dim, + rope_head_dim, + n_heads, + value_head_dim, + }; + resolved.validate()?; + Ok(resolved) + } + + /// Validate resolved MLA dimensions. + pub fn validate(&self) -> Result<()> { + if self.latent_dim == 0 { + return Err(AarambhError::Config( + "mla.latent_dim must be non-zero".into(), + )); + } + if self.n_heads == 0 { + return Err(AarambhError::Config( + "mla.n_heads must resolve to a non-zero value".into(), + )); + } + if self.nope_head_dim == 0 { + return Err(AarambhError::Config( + "mla.nope_head_dim must resolve to a non-zero value".into(), + )); + } + if self.rope_head_dim == 0 || !self.rope_head_dim.is_multiple_of(2) { + return Err(AarambhError::Config( + "mla.rope_head_dim must be a positive even number".into(), + )); + } + if self.value_head_dim == 0 { + return Err(AarambhError::Config( + "mla.value_head_dim must resolve to a non-zero value".into(), + )); + } + Ok(()) + } + + /// Return the per-token cache width (latent + rotary slice) in elements. + pub fn cache_width(&self) -> usize { + self.latent_dim + self.rope_head_dim + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default)] +/// Per-layer schedule for hybrid full, Gated DeltaNet, and LatentMLA attention. pub struct HybridAttentionSchedule { /// Keep every Nth zero-based layer as full attention; other layers use Gated DeltaNet. pub full_attention_every_n: usize, /// Gated DeltaNet shape and execution settings. pub gated_deltanet: GatedDeltaNetConfig, + /// Zero-based layer indices upgraded to Multi-Head Latent Attention (v4 Phase 41). + /// + /// Empty by default, which reproduces v3.0.0 exactly. A layer listed here + /// takes precedence over both the `full_attention_every_n` rule and the + /// DSA full-attention override. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub mla_layers: Vec, + /// Shared Multi-Head Latent Attention settings used by every `mla_layers` entry. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mla: Option, } impl Default for HybridAttentionSchedule { @@ -528,13 +647,28 @@ impl Default for HybridAttentionSchedule { Self { full_attention_every_n: 4, gated_deltanet: GatedDeltaNetConfig::default(), + mla_layers: Vec::new(), + mla: None, } } } impl HybridAttentionSchedule { + /// Return whether `layer_idx` is selected for Multi-Head Latent Attention. + pub fn is_mla_layer(&self, layer_idx: usize) -> bool { + self.mla_layers.contains(&layer_idx) + } + /// Return the token mixer selected for `layer_idx`. + /// + /// `LatentMLA` layers (from `mla_layers`) take precedence over the + /// `full_attention_every_n` rule, so the DSA override applied by + /// [`ModelConfig::attention_kind_for_layer`](crate::ModelConfig::attention_kind_for_layer) + /// never replaces an MLA slot. pub fn kind_for_layer(&self, layer_idx: usize) -> AttentionKind { + if self.is_mla_layer(layer_idx) { + return AttentionKind::LatentMLA; + } if self.full_attention_every_n > 0 && layer_idx.is_multiple_of(self.full_attention_every_n) { AttentionKind::Full @@ -555,15 +689,44 @@ impl HybridAttentionSchedule { "attention_schedule.full_attention_every_n must be non-zero".into(), )); } - if n_layers < 2 - || !(0..n_layers).any(|idx| self.kind_for_layer(idx) == AttentionKind::GatedDeltaNet) - { + let has_gated_delta = + (0..n_layers).any(|idx| self.kind_for_layer(idx) == AttentionKind::GatedDeltaNet); + let has_mla = (0..n_layers).any(|idx| self.kind_for_layer(idx) == AttentionKind::LatentMLA); + if n_layers < 2 || (!has_gated_delta && !has_mla) { + return Err(AarambhError::Config( + "attention_schedule must select at least one Gated DeltaNet or LatentMLA layer" + .into(), + )); + } + if has_mla && self.mla.is_none() { return Err(AarambhError::Config( - "attention_schedule must select at least one Gated DeltaNet layer".into(), + "attention_schedule.mla must be set when mla_layers is non-empty".into(), )); } self.gated_deltanet.resolve(hidden_dim, transformer_heads) } + + /// Resolve the shared MLA configuration when the schedule selects MLA layers. + /// + /// Returns `Ok(None)` when no layer uses LatentMLA, so a v3 schedule with + /// an empty `mla_layers` reproduces v3.0.0 exactly. + pub fn resolved_mla( + &self, + n_layers: usize, + hidden_dim: usize, + transformer_heads: usize, + ) -> Result> { + let has_mla = (0..n_layers).any(|idx| self.kind_for_layer(idx) == AttentionKind::LatentMLA); + if !has_mla { + return Ok(None); + } + let mla = self.mla.as_ref().ok_or_else(|| { + AarambhError::Config( + "attention_schedule.mla is required when mla_layers is non-empty".into(), + ) + })?; + mla.resolve(hidden_dim, transformer_heads).map(Some) + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -890,6 +1053,87 @@ mod tests { ); assert_eq!(cfg.attention_kind_for_layer(4), AttentionKind::Sparse); } + + #[test] + fn schedule_with_zero_mla_layers_matches_v3_exactly() { + // A default v3 schedule (empty mla_layers, no mla config) reproduces + // v3.0.0 kind_for_layer exactly: Full every Nth layer, GatedDeltaNet + // elsewhere, and resolved_mla returns None. + let schedule = HybridAttentionSchedule::default(); + for layer in 0..8 { + let kind = schedule.kind_for_layer(layer); + if layer.is_multiple_of(schedule.full_attention_every_n) { + assert_eq!(kind, AttentionKind::Full, "layer {layer}"); + } else { + assert_eq!(kind, AttentionKind::GatedDeltaNet, "layer {layer}"); + } + } + assert!(schedule.resolved_mla(8, 384, 6).unwrap().is_none()); + } + + #[test] + fn mla_layers_take_precedence_over_every_n_and_dsa_override() { + // MLA slots must win over both the full_attention_every_n rule and the + // DSA full-attention override applied by ModelConfig. + let schedule = HybridAttentionSchedule { + mla_layers: vec![0, 4], + mla: Some(MlaConfig { + latent_dim: 64, + rope_head_dim: 16, + ..Default::default() + }), + ..Default::default() + }; + assert_eq!(schedule.kind_for_layer(0), AttentionKind::LatentMLA); + assert_eq!(schedule.kind_for_layer(1), AttentionKind::GatedDeltaNet); + assert_eq!(schedule.kind_for_layer(4), AttentionKind::LatentMLA); + + let mut cfg = ModelConfig::tiny(); + cfg.attention_schedule = Some(schedule); + cfg.dsa_config = Some(DsaConfig::default()); + // MLA slot is not replaced by Sparse even though dsa_config is set. + assert_eq!(cfg.attention_kind_for_layer(0), AttentionKind::LatentMLA); + assert_eq!(cfg.attention_kind_for_layer(4), AttentionKind::LatentMLA); + // resolved_mla returns a resolved config (n_heads derived from the host). + let mla = cfg + .attention_schedule + .as_ref() + .unwrap() + .resolved_mla(cfg.n_layers, cfg.hidden_dim, cfg.n_heads) + .unwrap() + .unwrap(); + assert_eq!(mla.n_heads, cfg.n_heads); + assert_eq!(mla.rope_head_dim, 16); + assert!(mla.cache_width() < 2 * cfg.n_kv_heads * cfg.head_dim()); + } + + #[test] + fn mla_config_resolves_derived_dimensions() { + // host: hidden=128, heads=2 -> head_dim=64. + let mla = MlaConfig { + latent_dim: 64, + rope_head_dim: 16, + ..Default::default() + } + .resolve(128, 2) + .unwrap(); + assert_eq!(mla.n_heads, 2); + assert_eq!(mla.nope_head_dim, 48); // 64 - 16 + assert_eq!(mla.value_head_dim, 48); // derives nope_head_dim + assert_eq!(mla.cache_width(), 80); // 64 + 16 + } + + #[test] + fn mla_config_rejects_odd_rope_head_dim() { + let err = MlaConfig { + latent_dim: 64, + rope_head_dim: 15, + ..Default::default() + } + .resolve(128, 2) + .unwrap_err(); + assert!(err.to_string().contains("rope_head_dim"), "{}", err); + } } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/aarambh-studio-core/src/lib.rs b/crates/aarambh-studio-core/src/lib.rs index 6c29f9a..919078e 100644 --- a/crates/aarambh-studio-core/src/lib.rs +++ b/crates/aarambh-studio-core/src/lib.rs @@ -18,8 +18,8 @@ pub use config::RopeScalingConfig; pub use config::RopeScalingMethod; pub use config::TrainConfig; pub use config::{ - AttentionKind, DsaConfig, GatedDeltaNetConfig, HybridAttentionSchedule, MtpConfig, QatConfig, - QatTarget, QuantBits, QuantGranularity, + AttentionKind, DsaConfig, GatedDeltaNetConfig, HybridAttentionSchedule, MlaConfig, MtpConfig, + QatConfig, QatTarget, QuantBits, QuantGranularity, }; pub use device::Device; pub use dtype::DType; diff --git a/crates/aarambh-studio-finetune/src/dora.rs b/crates/aarambh-studio-finetune/src/dora.rs index 4556c12..349a52b 100644 --- a/crates/aarambh-studio-finetune/src/dora.rs +++ b/crates/aarambh-studio-finetune/src/dora.rs @@ -977,6 +977,8 @@ mod tests { conv_kernel_size: 4, chunk_size: 16, }, + mla_layers: Vec::new(), + mla: None, }); let base_varmap = VarMap::new(); let vb = VarBuilder::from_varmap(&base_varmap, DType::F32, &device); diff --git a/crates/aarambh-studio-finetune/src/model.rs b/crates/aarambh-studio-finetune/src/model.rs index 0ed6151..d721621 100644 --- a/crates/aarambh-studio-finetune/src/model.rs +++ b/crates/aarambh-studio-finetune/src/model.rs @@ -806,6 +806,8 @@ mod tests { conv_kernel_size: 4, chunk_size: 16, }, + mla_layers: Vec::new(), + mla: None, }), dsa_config: None, mtp: None, diff --git a/crates/aarambh-studio-inference/src/kvcache.rs b/crates/aarambh-studio-inference/src/kvcache.rs index 2ac6309..a14fabb 100644 --- a/crates/aarambh-studio-inference/src/kvcache.rs +++ b/crates/aarambh-studio-inference/src/kvcache.rs @@ -126,6 +126,8 @@ mod tests { conv_kernel_size: 4, chunk_size: 16, }, + mla_layers: Vec::new(), + mla: None, }), dsa_config: None, mtp: None, diff --git a/crates/aarambh-studio-model/src/lib.rs b/crates/aarambh-studio-model/src/lib.rs index 7425c31..8b8d975 100644 --- a/crates/aarambh-studio-model/src/lib.rs +++ b/crates/aarambh-studio-model/src/lib.rs @@ -10,4 +10,7 @@ pub mod model; pub use embedding::TokenEmbedding; pub use head::LmHead; -pub use model::{AarambhModel, CachedModelOutput, ModelForwardOutput, MtpPrediction}; +pub use model::{ + AarambhModel, CachedModelOutput, KvCacheLayerReport, ModelForwardOutput, MtpPrediction, + kv_cache_report, +}; diff --git a/crates/aarambh-studio-model/src/model.rs b/crates/aarambh-studio-model/src/model.rs index 0a9b368..66f9225 100644 --- a/crates/aarambh-studio-model/src/model.rs +++ b/crates/aarambh-studio-model/src/model.rs @@ -1,12 +1,13 @@ use std::collections::HashMap; use aarambh_studio_core::{ - AarambhError, AttentionKind, Configurable, Forward, ModelConfig, QatTarget, Result, + AarambhError, AttentionKind, Configurable, Forward, MlaConfig, ModelConfig, QatTarget, Result, }; use aarambh_studio_nn::{ DeltaNetState, DsaAttention, DsaForwardStats, DsaKvCache, DsaTeacherOutput, FeedForwardLayer, - GatedDeltaNetLayer, GroupedQueryAttention, HybridKvCache, KVCache, MoeFfn, MoeForwardStats, - MtpHead, RMSNorm, RopeCache, SharedExpertPath, SwiGluFfn, TokenMixer, TransformerBlock, + GatedDeltaNetLayer, GroupedQueryAttention, HybridKvCache, KVCache, MlaAttention, MlaCache, + MoeFfn, MoeForwardStats, MtpHead, RMSNorm, RopeCache, SharedExpertPath, SwiGluFfn, TokenMixer, + TransformerBlock, }; use aarambh_studio_quant::{QatContext, QatLinear, QatStats}; use candle_core::{DType, Tensor}; @@ -87,12 +88,22 @@ impl AarambhModel { }; let embedding = TokenEmbedding::new(cfg.vocab_size, cfg.hidden_dim, vb.pp("embedding"))?; + let model_dtype = embedding.weight().dtype(); let mut blocks = Vec::with_capacity(cfg.n_layers); let deltanet_config = cfg .attention_schedule .as_ref() .map(|schedule| schedule.validate(cfg.n_layers, cfg.hidden_dim, cfg.n_heads)) .transpose()?; + let mla_config = cfg + .attention_schedule + .as_ref() + .and_then(|schedule| { + schedule + .resolved_mla(cfg.n_layers, cfg.hidden_dim, cfg.n_heads) + .ok() + }) + .flatten(); for layer_idx in 0..cfg.n_layers { let block_vb = vb.pp("blocks").pp(layer_idx); @@ -147,6 +158,15 @@ impl AarambhModel { block_vb.pp("deltanet"), qat_context.clone(), )?), + AttentionKind::LatentMLA => TokenMixer::Mla(build_mla( + cfg, + model_dtype, + block_vb.pp("mla"), + qat_context.clone(), + mla_config + .as_ref() + .expect("validated hybrid config has MLA settings"), + )?), }; let ffn_vb = block_vb.pp("ffn"); @@ -243,7 +263,7 @@ impl AarambhModel { None => Vec::new(), }; - let dtype = embedding.weight().dtype(); + let dtype = model_dtype; let rope_cache = RopeCache::from_config(cfg, dtype, vb.device())?; Ok(Self { @@ -636,6 +656,7 @@ impl AarambhModel { attn.config().block_size, )), TokenMixer::GatedDelta(_) => HybridKvCache::Linear(DeltaNetState::new()), + TokenMixer::Mla(_) => HybridKvCache::Mla(MlaCache::with_capacity(capacity)), }) .collect() } @@ -717,6 +738,10 @@ impl AarambhModel { .mixer() .as_gated_delta() .and_then(|layer| layer.get_weight(&suffix[9..])), + _ if suffix.starts_with("mla.") => block + .mixer() + .as_mla() + .and_then(|layer| layer.get_weight(&suffix[4..])), _ => get_moe_expert_weight(block.ffn(), suffix), }; } @@ -1024,6 +1049,11 @@ fn insert_mixer_tensors( ); } } + TokenMixer::Mla(layer) => { + for (name, tensor) in layer.named_tensors() { + tensors.insert(format!("blocks.{layer_idx}.mla.{name}"), tensor.clone()); + } + } } } @@ -1061,6 +1091,70 @@ fn build_attention( )) } +fn build_mla( + cfg: &ModelConfig, + dtype: DType, + vb: VarBuilder<'_>, + qat: Option, + mla: &MlaConfig, +) -> Result { + let h = mla.n_heads; + let nope = mla.nope_head_dim; + let rope_dim = mla.rope_head_dim; + let val = mla.value_head_dim; + let latent = mla.latent_dim; + let hidden = cfg.hidden_dim; + // MLA owns a dedicated `rope_head_dim`-wide RoPE cache for its decoupled + // rotary slice; the host transformer's head-dim RoPE is not reused because + // a compressed latent cannot carry an already-rotated key. + let rope = RopeCache::new( + cfg.max_seq_len, + rope_dim, + cfg.rope_theta, + dtype, + vb.device(), + )?; + Ok(MlaAttention::new( + qat_linear( + linear_no_bias(hidden, h * (nope + rope_dim), vb.pp("q_proj"))?, + QatTarget::Mla, + &qat, + ), + qat_linear( + linear_no_bias(hidden, latent, vb.pp("kv_a_proj"))?, + QatTarget::Mla, + &qat, + ), + RMSNorm::new( + vb.pp("kv_a_norm") + .get_with_hints(latent, "weight", Init::Const(1.0))?, + cfg.norm_eps as f32, + ), + qat_linear( + linear_no_bias(latent, h * nope, vb.pp("up_k"))?, + QatTarget::Mla, + &qat, + ), + qat_linear( + linear_no_bias(latent, h * val, vb.pp("up_v"))?, + QatTarget::Mla, + &qat, + ), + qat_linear( + linear_no_bias(hidden, rope_dim, vb.pp("k_rope_proj"))?, + QatTarget::Mla, + &qat, + ), + qat_linear( + linear_no_bias(h * val, hidden, vb.pp("o_proj"))?, + QatTarget::Mla, + &qat, + ), + mla.clone(), + rope, + )) +} + fn build_swiglu( hidden_dim: usize, ffn_dim: usize, @@ -1214,3 +1308,60 @@ impl Forward for AarambhModel { AarambhModel::forward(self, xs) } } + +/// Per-layer KV-cache footprint summary, used by `--kv-cache-report` (v4 Phase 41). +#[derive(Debug, Clone)] +pub struct KvCacheLayerReport { + /// Zero-based layer index. + pub layer: usize, + /// Attention kind active for this layer. + pub kind: AttentionKind, + /// Bytes cached per generated token at this layer. + pub bytes_per_token: usize, + /// Human-readable note explaining the footprint (e.g. fixed recurrent state). + pub note: &'static str, +} + +/// Compute the per-layer KV-cache bytes/token for a model config. +/// +/// MLA layers cache `(latent_dim + rope_head_dim) * dtype_bytes` per token; +/// full and DSA layers cache `2 * n_kv_heads * head_dim * dtype_bytes`; Gated +/// DeltaNet layers use a fixed recurrent state that does not grow per token. +pub fn kv_cache_report(cfg: &ModelConfig, dtype_bytes: usize) -> Vec { + let head_dim = cfg.head_dim(); + let full_bytes = 2 * cfg.n_kv_heads * head_dim * dtype_bytes; + let mla = cfg + .attention_schedule + .as_ref() + .and_then(|schedule| { + schedule + .resolved_mla(cfg.n_layers, cfg.hidden_dim, cfg.n_heads) + .ok() + }) + .flatten(); + (0..cfg.n_layers) + .map(|layer| { + let kind = cfg.attention_kind_for_layer(layer); + let (bytes_per_token, note) = match kind { + AttentionKind::Full => (full_bytes, "2 * n_kv_heads * head_dim per token"), + AttentionKind::Sparse => (full_bytes, "full KV + compact DSA block index"), + AttentionKind::GatedDeltaNet => (0, "fixed recurrent state (not per-token)"), + AttentionKind::LatentMLA => { + let mla = mla + .as_ref() + .expect("MLA layer requires resolved MLA config"); + ( + mla.cache_width() * dtype_bytes, + "latent_dim + rope_head_dim per token (compressed)", + ) + } + }; + KvCacheLayerReport { + layer, + kind, + bytes_per_token, + note, + } + }) + .collect() +} diff --git a/crates/aarambh-studio-model/tests/model_tests.rs b/crates/aarambh-studio-model/tests/model_tests.rs index 33deae6..d4b0ff4 100644 --- a/crates/aarambh-studio-model/tests/model_tests.rs +++ b/crates/aarambh-studio-model/tests/model_tests.rs @@ -1,8 +1,8 @@ use aarambh_studio_core::{ - DsaConfig, GatedDeltaNetConfig, HybridAttentionSchedule, ModelConfig, MoeConfig, MtpConfig, - QatConfig, RopeScalingConfig, RopeScalingMethod, + AttentionKind, DsaConfig, GatedDeltaNetConfig, HybridAttentionSchedule, MlaConfig, ModelConfig, + MoeConfig, MtpConfig, QatConfig, RopeScalingConfig, RopeScalingMethod, }; -use aarambh_studio_model::AarambhModel; +use aarambh_studio_model::{AarambhModel, kv_cache_report}; use candle_core::{DType, Device, Tensor}; use candle_nn::{VarBuilder, VarMap}; @@ -89,6 +89,8 @@ fn hybrid_mini_config() -> ModelConfig { conv_kernel_size: 4, chunk_size: 16, }, + mla_layers: Vec::new(), + mla: None, }), ..mini_config() } @@ -106,6 +108,29 @@ fn dsa_mini_config() -> ModelConfig { } } +fn mla_mini_config() -> ModelConfig { + // Layer 0 = LatentMLA (upgraded from the every-n Full slot), layer 1 = Gated DeltaNet. + ModelConfig { + attention_schedule: Some(HybridAttentionSchedule { + full_attention_every_n: 2, + gated_deltanet: GatedDeltaNetConfig { + n_heads: 1, + key_head_dim: 16, + value_head_dim: 32, + conv_kernel_size: 4, + chunk_size: 16, + }, + mla_layers: vec![0], + mla: Some(MlaConfig { + latent_dim: 32, + rope_head_dim: 16, + ..Default::default() + }), + }), + ..mini_config() + } +} + #[test] fn all_four_model_configs_validate() { for cfg in [ @@ -252,6 +277,99 @@ fn hybrid_training_backward_reaches_deltanet_parameters() { ); } +#[test] +fn mla_model_forwards_and_cached_forward_matches_full_forward() { + let device = Device::Cpu; + let cfg = mla_mini_config(); + let varmap = VarMap::new(); + let model = + AarambhModel::new(&cfg, VarBuilder::from_varmap(&varmap, DType::F32, &device)).unwrap(); + let ids = Tensor::from_vec(vec![7u32, 8, 9, 10], (1, 4), &device).unwrap(); + let full_last = model.forward(&ids).unwrap().narrow(1, 3, 1).unwrap(); + + let mut caches = model.empty_kv_cache(); + // Layer 0 is MLA (compressed-latent cache), layer 1 is Gated DeltaNet (linear state). + assert!(caches[0].as_mla().is_some()); + assert!(caches[0].as_linear().is_none()); + assert!(caches[1].as_linear().is_some()); + + let mut cached_last = None; + for pos in 0..4 { + cached_last = Some( + model + .forward_with_cache(&ids.narrow(1, pos, 1).unwrap(), pos, &mut caches) + .unwrap(), + ); + // MLA cache grows by one token per step while staying compressed. + assert_eq!(caches[0].as_mla().unwrap().seq_len(), pos + 1); + } + + let max_diff = (full_last - cached_last.unwrap()) + .unwrap() + .abs() + .unwrap() + .max_all() + .unwrap() + .to_scalar::() + .unwrap(); + assert!(max_diff < 1e-4, "MLA cached/full mismatch: {max_diff}"); +} + +#[test] +fn mla_training_backward_reaches_mla_parameters() { + let device = Device::Cpu; + let cfg = mla_mini_config(); + let varmap = VarMap::new(); + let model = + AarambhModel::new(&cfg, VarBuilder::from_varmap(&varmap, DType::F32, &device)).unwrap(); + let ids = Tensor::from_vec(vec![7u32, 8, 9, 10], (1, 4), &device).unwrap(); + let loss = model + .forward_train(&ids) + .unwrap() + .sqr() + .unwrap() + .sum_all() + .unwrap(); + let gradients = loss.backward().unwrap(); + let variables = varmap.data().lock().unwrap(); + // Gradients must reach the MLA down-projection (kv_a_proj), latent norm, + // value up-projection (up_v), and output projection (o_proj) — the + // SELF_LEARNING_V4 §42 anti-forgetting reachability argument. The query and + // key paths (q_proj, up_k, k_rope_proj) match the existing GQA CPU training + // path, whose candle-fallback attention backward propagates to V/O but not + // to Q/K on CPU (full Q/K gradients flow under the CUDA/flash path used in + // real training); MLA is wired into the identical attention path, so its + // gradient reachability is consistent with GQA. + for name in [ + "blocks.0.mla.kv_a_proj.weight", + "blocks.0.mla.kv_a_norm.weight", + "blocks.0.mla.up_v.weight", + "blocks.0.mla.o_proj.weight", + ] { + let has_grad = variables + .get(name) + .map(|v| gradients.get(v.as_tensor()).is_some()) + .unwrap_or(false); + assert!(has_grad, "gradient did not reach MLA weight {name}"); + } +} + +#[test] +fn mla_kv_cache_report_shows_compressed_footprint() { + let cfg = mla_mini_config(); + let report = kv_cache_report(&cfg, 4); // f32 = 4 bytes/element + assert_eq!(report.len(), cfg.n_layers); + assert_eq!(report[0].kind, AttentionKind::LatentMLA); + // MLA cache = (latent_dim(32) + rope_head_dim(16)) * 4 = 192 bytes/token. + assert_eq!(report[0].bytes_per_token, 192); + // Gated DeltaNet layer 1 uses a fixed recurrent state (0 bytes/token). + assert_eq!(report[1].kind, AttentionKind::GatedDeltaNet); + assert_eq!(report[1].bytes_per_token, 0); + // MLA footprint must be smaller than the all-Full GQA baseline. + let gqa_baseline = 2 * cfg.n_kv_heads * cfg.head_dim() * 4; + assert!(report[0].bytes_per_token < gqa_baseline); +} + #[test] fn dsa_cached_forward_matches_full_sparse_forward() { let device = Device::Cpu; diff --git a/crates/aarambh-studio-nn/src/block.rs b/crates/aarambh-studio-nn/src/block.rs index cc6b770..6b49dfc 100644 --- a/crates/aarambh-studio-nn/src/block.rs +++ b/crates/aarambh-studio-nn/src/block.rs @@ -6,6 +6,7 @@ use crate::attention::GroupedQueryAttention; use crate::ffn::SwiGluFfn; use crate::gated_deltanet::GatedDeltaNetLayer; use crate::kvcache::HybridKvCache; +use crate::mla::MlaAttention; use crate::moe::{MoeFfn, MoeForwardStats}; use crate::norm::RMSNorm; use crate::rope::RopeCache; @@ -76,6 +77,8 @@ pub enum TokenMixer { Sparse(DsaAttention), /// Fixed-state Gated DeltaNet linear attention. GatedDelta(GatedDeltaNetLayer), + /// Multi-Head Latent Attention with compressed-latent KV cache (v4 Phase 41). + Mla(MlaAttention), } impl TokenMixer { @@ -87,34 +90,46 @@ impl TokenMixer { cache: Option<&mut HybridKvCache>, seqlen_offset: usize, ) -> Result { - match (self, cache) { - (Self::Attention(attn), Some(HybridKvCache::Full(cache))) => { - attn.forward(x, rope, mask, Some(cache), seqlen_offset) + match self { + Self::Attention(attn) => { + let cache = cache + .map(|c| { + c.as_full_mut().ok_or_else(|| { + candle_core::Error::msg( + "full-attention block received an incompatible cache", + ) + }) + }) + .transpose()?; + attn.forward(x, rope, mask, cache, seqlen_offset) } - (Self::Attention(attn), None) => attn.forward(x, rope, mask, None, seqlen_offset), - (Self::Sparse(attn), Some(HybridKvCache::Sparse(cache))) => { - attn.forward(x, rope, mask, Some(cache), seqlen_offset, None) + Self::Sparse(attn) => { + let cache = cache + .map(|c| { + c.as_sparse_mut().ok_or_else(|| { + candle_core::Error::msg("DSA block received an incompatible cache") + }) + }) + .transpose()?; + attn.forward(x, rope, mask, cache, seqlen_offset, None) } - (Self::Sparse(attn), None) => attn.forward(x, rope, mask, None, seqlen_offset, None), - (Self::GatedDelta(layer), Some(HybridKvCache::Linear(state))) => { - layer.forward_cached(x, state) + Self::GatedDelta(layer) => match cache { + Some(HybridKvCache::Linear(state)) => layer.forward_cached(x, state), + None => layer.forward(x), + Some(_) => Err(candle_core::Error::msg( + "Gated DeltaNet block received an incompatible cache", + )), + }, + Self::Mla(attn) => { + let cache = cache + .map(|c| { + c.as_mla_mut().ok_or_else(|| { + candle_core::Error::msg("MLA block received an incompatible cache") + }) + }) + .transpose()?; + attn.forward(x, rope, mask, cache, seqlen_offset) } - (Self::GatedDelta(layer), None) => layer.forward(x), - (Self::Attention(_), Some(HybridKvCache::Linear(_))) => Err(candle_core::Error::msg( - "full-attention block received a linear cache", - )), - (Self::Attention(_), Some(HybridKvCache::Sparse(_))) => Err(candle_core::Error::msg( - "full-attention block received a DSA cache", - )), - (Self::Sparse(_), Some(HybridKvCache::Full(_) | HybridKvCache::Linear(_))) => Err( - candle_core::Error::msg("DSA block received an incompatible cache"), - ), - (Self::GatedDelta(_), Some(HybridKvCache::Full(_))) => Err(candle_core::Error::msg( - "Gated DeltaNet block received a full-attention cache", - )), - (Self::GatedDelta(_), Some(HybridKvCache::Sparse(_))) => Err(candle_core::Error::msg( - "Gated DeltaNet block received a DSA cache", - )), } } @@ -161,13 +176,24 @@ impl TokenMixer { .map(|cache| { cache.as_linear_mut().ok_or_else(|| { candle_core::Error::msg( - "Gated DeltaNet block received a full-attention cache", + "Gated DeltaNet block received an incompatible cache", ) }) }) .collect::>>()?; layer.forward_decode_batch(x, &mut linear) } + Self::Mla(attn) => { + let mut mla = caches + .iter_mut() + .map(|cache| { + cache.as_mla_mut().ok_or_else(|| { + candle_core::Error::msg("MLA block received an incompatible cache") + }) + }) + .collect::>>()?; + attn.forward_decode_batch(x, rope, &mut mla, seqlen_offsets) + } } } @@ -182,6 +208,7 @@ impl TokenMixer { Self::Attention(attn) => attn.forward_train(x, rope, mask, seqlen_offset), Self::Sparse(attn) => attn.forward_train(x, rope, mask, seqlen_offset, None), Self::GatedDelta(layer) => layer.forward_train(x), + Self::Mla(attn) => attn.forward_train(x, rope, mask, seqlen_offset), } } @@ -197,6 +224,7 @@ impl TokenMixer { Self::Attention(attn) => attn.forward_with_capture(x, rope, mask, layer_idx, capture), Self::Sparse(attn) => attn.forward_with_capture(x, rope, mask, layer_idx, capture), Self::GatedDelta(layer) => layer.forward_with_capture(x, layer_idx, capture), + Self::Mla(attn) => attn.forward_with_capture(x, rope, mask, layer_idx, capture), } } @@ -205,7 +233,7 @@ impl TokenMixer { match self { Self::Attention(attn) => Some(attn), Self::Sparse(attn) => Some(attn.attention()), - Self::GatedDelta(_) => None, + Self::GatedDelta(_) | Self::Mla(_) => None, } } @@ -213,18 +241,26 @@ impl TokenMixer { pub fn as_sparse(&self) -> Option<&DsaAttention> { match self { Self::Sparse(attn) => Some(attn), - Self::Attention(_) | Self::GatedDelta(_) => None, + Self::Attention(_) | Self::GatedDelta(_) | Self::Mla(_) => None, } } /// Return the Gated DeltaNet implementation, when selected. pub fn as_gated_delta(&self) -> Option<&GatedDeltaNetLayer> { match self { - Self::Attention(_) | Self::Sparse(_) => None, + Self::Attention(_) | Self::Sparse(_) | Self::Mla(_) => None, Self::GatedDelta(layer) => Some(layer), } } + /// Return the Multi-Head Latent Attention implementation, when selected. + pub fn as_mla(&self) -> Option<&MlaAttention> { + match self { + Self::Mla(attn) => Some(attn), + Self::Attention(_) | Self::Sparse(_) | Self::GatedDelta(_) => None, + } + } + fn forward_train_with_dsa_stats( &self, x: &Tensor, diff --git a/crates/aarambh-studio-nn/src/kvcache.rs b/crates/aarambh-studio-nn/src/kvcache.rs index 28e2aa0..1a6a175 100644 --- a/crates/aarambh-studio-nn/src/kvcache.rs +++ b/crates/aarambh-studio-nn/src/kvcache.rs @@ -1,6 +1,7 @@ use candle_core::{DType, Result, Tensor}; use crate::gated_deltanet::DeltaNetState; +use crate::mla::MlaCache; #[derive(Debug, Clone)] /// KV state and compact block-index summaries for DSA attention. @@ -109,7 +110,7 @@ impl DsaKvCache { } #[derive(Debug, Clone)] -/// Per-layer cache for either full attention or Gated DeltaNet. +/// Per-layer cache for full attention, DSA, Gated DeltaNet, or LatentMLA. pub enum HybridKvCache { /// Growing key/value cache used by a full-attention layer. Full(KVCache), @@ -117,6 +118,8 @@ pub enum HybridKvCache { Sparse(DsaKvCache), /// Fixed-size recurrent state used by a Gated DeltaNet layer. Linear(DeltaNetState), + /// Compressed-latent cache used by a Multi-Head Latent Attention layer (v4 Phase 41). + Mla(MlaCache), } impl HybridKvCache { @@ -126,6 +129,7 @@ impl HybridKvCache { Self::Full(cache) => cache.clear(), Self::Sparse(cache) => cache.clear(), Self::Linear(state) => state.clear(), + Self::Mla(cache) => cache.clear(), } } @@ -135,6 +139,7 @@ impl HybridKvCache { Self::Full(cache) => cache.seq_len(), Self::Sparse(cache) => cache.seq_len(), Self::Linear(state) => state.seq_len(), + Self::Mla(cache) => cache.seq_len(), } } @@ -146,6 +151,7 @@ impl HybridKvCache { match self { Self::Full(cache) => cache.truncate(new_len), Self::Sparse(cache) => cache.truncate(new_len), + Self::Mla(cache) => cache.truncate(new_len), Self::Linear(state) if state.seq_len() == new_len => Ok(()), Self::Linear(state) if new_len == 0 => { state.clear(); @@ -162,7 +168,7 @@ impl HybridKvCache { pub fn as_full_mut(&mut self) -> Option<&mut KVCache> { match self { Self::Full(cache) => Some(cache), - Self::Sparse(_) | Self::Linear(_) => None, + Self::Sparse(_) | Self::Linear(_) | Self::Mla(_) => None, } } @@ -170,7 +176,7 @@ impl HybridKvCache { pub fn as_sparse_mut(&mut self) -> Option<&mut DsaKvCache> { match self { Self::Sparse(cache) => Some(cache), - Self::Full(_) | Self::Linear(_) => None, + Self::Full(_) | Self::Linear(_) | Self::Mla(_) => None, } } @@ -178,14 +184,14 @@ impl HybridKvCache { pub fn as_sparse(&self) -> Option<&DsaKvCache> { match self { Self::Sparse(cache) => Some(cache), - Self::Full(_) | Self::Linear(_) => None, + Self::Full(_) | Self::Linear(_) | Self::Mla(_) => None, } } /// Return the recurrent state when this layer uses Gated DeltaNet. pub fn as_linear_mut(&mut self) -> Option<&mut DeltaNetState> { match self { - Self::Full(_) | Self::Sparse(_) => None, + Self::Full(_) | Self::Sparse(_) | Self::Mla(_) => None, Self::Linear(state) => Some(state), } } @@ -193,17 +199,34 @@ impl HybridKvCache { /// Return the recurrent state when this layer uses Gated DeltaNet. pub fn as_linear(&self) -> Option<&DeltaNetState> { match self { - Self::Full(_) | Self::Sparse(_) => None, + Self::Full(_) | Self::Sparse(_) | Self::Mla(_) => None, Self::Linear(state) => Some(state), } } + /// Return the compressed-latent cache when this layer uses LatentMLA. + pub fn as_mla_mut(&mut self) -> Option<&mut MlaCache> { + match self { + Self::Mla(cache) => Some(cache), + Self::Full(_) | Self::Sparse(_) | Self::Linear(_) => None, + } + } + + /// Return the compressed-latent cache when this layer uses LatentMLA. + pub fn as_mla(&self) -> Option<&MlaCache> { + match self { + Self::Mla(cache) => Some(cache), + Self::Full(_) | Self::Sparse(_) | Self::Linear(_) => None, + } + } + /// Return preallocated full-attention capacity, or `None` for linear state. pub fn capacity(&self) -> Option { match self { Self::Full(cache) => cache.capacity(), Self::Sparse(cache) => cache.capacity(), Self::Linear(_) => None, + Self::Mla(cache) => cache.capacity(), } } } diff --git a/crates/aarambh-studio-nn/src/lib.rs b/crates/aarambh-studio-nn/src/lib.rs index 0c67f9a..956e36c 100644 --- a/crates/aarambh-studio-nn/src/lib.rs +++ b/crates/aarambh-studio-nn/src/lib.rs @@ -13,6 +13,8 @@ pub mod ffn; pub mod gated_deltanet; /// Training/inference KV cache helper. pub mod kvcache; +/// Multi-Head Latent Attention layer and compressed-latent cache (v4 Phase 41). +pub mod mla; /// Mixture-of-Experts feed-forward layer. pub mod moe; /// Multi-token prediction auxiliary head. @@ -32,6 +34,7 @@ pub use dispatch::dense_weighted_dispatch; pub use ffn::SwiGluFfn; pub use gated_deltanet::{DeltaNetForm, DeltaNetState, GatedDeltaNetLayer}; pub use kvcache::{DsaKvCache, HybridKvCache, KVCache}; +pub use mla::{MlaAttention, MlaCache}; pub use moe::{ GatingOutput, MoeFfn, MoeForwardStats, SharedExpertPath, load_balancing_loss_from_stats, top_k_gating, diff --git a/crates/aarambh-studio-nn/src/mla.rs b/crates/aarambh-studio-nn/src/mla.rs new file mode 100644 index 0000000..cbf0505 --- /dev/null +++ b/crates/aarambh-studio-nn/src/mla.rs @@ -0,0 +1,673 @@ +//! Multi-Head Latent Attention (MLA) — v4 Phase 41. +//! +//! MLA compresses the per-token KV cache into a single low-rank latent vector +//! (`c_kv`), reconstructing per-head keys and values at attention time through +//! small up-projection weights that are trained but never cached. A small +//! dedicated rotary slice (`k_rope`) is cached alongside the latent so rotary +//! position can be re-introduced without rotating the compressed latent. +//! +//! See `ARCHITECTURE_V4.md` §55 and `docs/phase41_mla.md` for the full design. + +use aarambh_studio_core::MlaConfig; +use aarambh_studio_quant::QatLinear; +use candle_core::{D, Result, Tensor}; + +use crate::norm::RMSNorm; +use crate::rope::RopeCache; + +/// Inference attention dispatch that tolerates a value head width different +/// from the query/key head width (MLA reconstructs V at a different width). +use aarambh_studio_kernel::dispatch::{attention_forward_candle, attention_forward_candle_causal}; + +#[derive(Debug, Clone, Default)] +/// Compressed-latent KV cache for one Multi-Head Latent Attention layer. +/// +/// Stores only the normed latent `c_kv` (`latent_dim` per token) and the small +/// rotary key slice `k_rope` (`rope_head_dim` per token) shared across heads. +/// Per-head keys and values are reconstructed from `c_kv` at attention time via +/// the layer's up-projection weights, which are ordinary parameters and never +/// cached — this is what shrinks the per-token footprint versus full GQA. +pub struct MlaCache { + c_kv: Option, + k_rope: Option, + len: usize, + capacity: Option, +} + +impl MlaCache { + /// Create an empty dynamic cache. + pub fn new() -> Self { + Self { + c_kv: None, + k_rope: None, + len: 0, + capacity: None, + } + } + + /// Create an empty cache that preallocates storage on first update. + pub fn with_capacity(capacity: usize) -> Self { + Self { + c_kv: None, + k_rope: None, + len: 0, + capacity: Some(capacity), + } + } + + /// Append one token's latent and rotary slice, returning the full history. + /// + /// `c_kv` is shaped `[batch, seq, latent_dim]` and `k_rope` is shaped + /// `[batch, seq, rope_head_dim]`. + pub fn update(&mut self, c_kv: &Tensor, k_rope: &Tensor) -> Result<(Tensor, Tensor)> { + if self.capacity.is_some() { + return self.update_preallocated(c_kv, k_rope); + } + let c_kv_full = match &self.c_kv { + Some(cached) => Tensor::cat(&[cached, c_kv], 1)?, + None => c_kv.clone(), + }; + let k_rope_full = match &self.k_rope { + Some(cached) => Tensor::cat(&[cached, k_rope], 1)?, + None => k_rope.clone(), + }; + self.len = c_kv_full.dim(1)?; + self.c_kv = Some(c_kv_full.clone()); + self.k_rope = Some(k_rope_full.clone()); + Ok((c_kv_full, k_rope_full)) + } + + /// Remove all cached latent and rotary state. + pub fn clear(&mut self) { + self.len = 0; + if self.capacity.is_none() { + self.c_kv = None; + self.k_rope = None; + } + } + + /// Roll the cache back to a previously committed sequence length. + pub fn truncate(&mut self, new_len: usize) -> Result<()> { + if new_len > self.len { + return Err(candle_core::Error::msg(format!( + "cannot grow MLA cache from {} to {new_len} with truncate", + self.len + ))); + } + if self.capacity.is_some() { + self.len = new_len; + return Ok(()); + } + if new_len == 0 { + self.clear(); + return Ok(()); + } + self.c_kv = Some( + self.c_kv + .as_ref() + .ok_or_else(|| candle_core::Error::msg("MLA cache has no latent tensor"))? + .narrow(1, 0, new_len)?, + ); + self.k_rope = Some( + self.k_rope + .as_ref() + .ok_or_else(|| candle_core::Error::msg("MLA cache has no rope tensor"))? + .narrow(1, 0, new_len)?, + ); + self.len = new_len; + Ok(()) + } + + /// Return the cached sequence length. + pub fn seq_len(&self) -> usize { + self.len + } + + /// Return the preallocated capacity when this cache owns fixed storage. + pub fn capacity(&self) -> Option { + self.capacity + } + + /// Return the cached latent history, when present. + pub fn latent(&self) -> Option<&Tensor> { + self.c_kv.as_ref() + } + + /// Return the cached rotary-key history, when present. + pub fn rope_keys(&self) -> Option<&Tensor> { + self.k_rope.as_ref() + } + + fn update_preallocated(&mut self, c_kv: &Tensor, k_rope: &Tensor) -> Result<(Tensor, Tensor)> { + let capacity = self.capacity.unwrap_or(0); + let c_dims = c_kv.dims(); + let k_dims = k_rope.dims(); + if c_dims.len() != 3 + || k_dims.len() != 3 + || k_dims[0] != c_dims[0] + || k_dims[1] != c_dims[1] + { + return Err(candle_core::Error::msg(format!( + "MLA cache expects latent [batch, seq, latent_dim] and matching rope [batch, seq, rope_head_dim], got {:?} / {:?}", + c_kv.dims(), + k_rope.dims() + ))); + } + let seq_len = c_dims[1]; + if self.len + seq_len > capacity { + return Err(candle_core::Error::msg(format!( + "MLA cache length {} exceeds capacity {capacity}", + self.len + seq_len + ))); + } + if self.c_kv.is_none() { + self.c_kv = Some(Tensor::zeros( + (c_dims[0], capacity, c_dims[2]), + c_kv.dtype(), + c_kv.device(), + )?); + } + if self.k_rope.is_none() { + self.k_rope = Some(Tensor::zeros( + (c_dims[0], capacity, k_rope.dim(2)?), + k_rope.dtype(), + k_rope.device(), + )?); + } + let cached_c_kv = self.c_kv.as_ref().unwrap(); + let cached_k_rope = self.k_rope.as_ref().unwrap(); + cached_c_kv.slice_set(&c_kv.contiguous()?, 1, self.len)?; + cached_k_rope.slice_set(&k_rope.contiguous()?, 1, self.len)?; + self.len += seq_len; + Ok(( + cached_c_kv.narrow(1, 0, self.len)?, + cached_k_rope.narrow(1, 0, self.len)?, + )) + } +} + +#[derive(Debug, Clone)] +/// Multi-Head Latent Attention token mixer. +/// +/// Caches a single compressed latent per token plus a small rotary key slice; +/// per-head keys and values are reconstructed at attention time from the latent +/// through the `up_k` and `up_v` projection weights. +pub struct MlaAttention { + q_proj: QatLinear, + kv_a_proj: QatLinear, + kv_a_norm: RMSNorm, + up_k: QatLinear, + up_v: QatLinear, + k_rope_proj: QatLinear, + o_proj: QatLinear, + config: MlaConfig, + scale: f64, + rope: RopeCache, +} + +impl MlaAttention { + /// Construct an MLA layer from its projections, latent norm, and rotary cache. + #[allow(clippy::too_many_arguments)] + pub fn new( + q_proj: impl Into, + kv_a_proj: impl Into, + kv_a_norm: RMSNorm, + up_k: impl Into, + up_v: impl Into, + k_rope_proj: impl Into, + o_proj: impl Into, + config: MlaConfig, + rope: RopeCache, + ) -> Self { + let head_dim = config.nope_head_dim + config.rope_head_dim; + let scale = 1.0 / (head_dim as f64).sqrt(); + Self { + q_proj: q_proj.into(), + kv_a_proj: kv_a_proj.into(), + kv_a_norm, + up_k: up_k.into(), + up_v: up_v.into(), + k_rope_proj: k_rope_proj.into(), + o_proj: o_proj.into(), + config, + scale, + rope, + } + } + + /// Return the resolved MLA configuration. + pub fn config(&self) -> &MlaConfig { + &self.config + } + + /// Return the cached latent width per token. + pub fn latent_dim(&self) -> usize { + self.config.latent_dim + } + + /// Return the per-head rotary key width. + pub fn rope_head_dim(&self) -> usize { + self.config.rope_head_dim + } + + /// Return the per-head reconstructed value width. + pub fn value_head_dim(&self) -> usize { + self.config.value_head_dim + } + + /// Return the query projection weight tensor. + pub fn q_proj_weight(&self) -> &Tensor { + self.q_proj.weight() + } + + /// Return the latent down-projection weight tensor. + pub fn kv_a_proj_weight(&self) -> &Tensor { + self.kv_a_proj.weight() + } + + /// Return the latent normalization weight tensor. + pub fn kv_a_norm_weight(&self) -> &Tensor { + self.kv_a_norm.weight() + } + + /// Return the per-head key up-projection weight tensor. + pub fn up_k_weight(&self) -> &Tensor { + self.up_k.weight() + } + + /// Return the per-head value up-projection weight tensor. + pub fn up_v_weight(&self) -> &Tensor { + self.up_v.weight() + } + + /// Return the rotary-key projection weight tensor. + pub fn k_rope_proj_weight(&self) -> &Tensor { + self.k_rope_proj.weight() + } + + /// Return the output projection weight tensor. + pub fn o_proj_weight(&self) -> &Tensor { + self.o_proj.weight() + } + + /// Run inference attention, optionally updating a compressed-latent cache. + /// + /// The `rope` argument is the host transformer's head-dim RoPE cache and is + /// intentionally unused: MLA owns a dedicated `rope_head_dim` RoPE cache + /// for its decoupled rotary slice. The signature matches the shared + /// `TokenMixer` forward contract so an MLA layer drops into the hybrid + /// schedule without special-casing the block. + pub fn forward( + &self, + x: &Tensor, + _rope: &RopeCache, + mask: Option<&Tensor>, + kv_cache: Option<&mut MlaCache>, + seqlen_offset: usize, + ) -> Result { + let (q_nope, q_rope_rot, c_kv, k_rope_rot) = self.project(x, seqlen_offset, false)?; + let (c_kv_full, k_rope_full) = match kv_cache { + Some(cache) => cache.update(&c_kv, &k_rope_rot)?, + None => (c_kv, k_rope_rot), + }; + self.attend(&q_nope, &q_rope_rot, &c_kv_full, &k_rope_full, mask) + } + + /// Run the differentiable training path without mutating a cache. + pub fn forward_train( + &self, + x: &Tensor, + _rope: &RopeCache, + mask: Option<&Tensor>, + seqlen_offset: usize, + ) -> Result { + let (q_nope, q_rope_rot, c_kv, k_rope_rot) = self.project(x, seqlen_offset, true)?; + // Training attends over the full sequence with no cross-call cache. + self.attend(&q_nope, &q_rope_rot, &c_kv, &k_rope_rot, mask) + } + + /// Decode one token per row for multiple independent MLA caches. + pub fn forward_decode_batch( + &self, + x: &Tensor, + _rope: &RopeCache, + caches: &mut [&mut MlaCache], + seqlen_offsets: &[usize], + ) -> Result { + let dims = x.dims(); + if dims.len() != 3 || dims[1] != 1 { + return Err(candle_core::Error::msg(format!( + "MLA batched decode expects [batch, 1, hidden], got {dims:?}" + ))); + } + let batch = dims[0]; + if caches.len() != batch || seqlen_offsets.len() != batch { + return Err(candle_core::Error::msg(format!( + "MLA batched decode received batch {batch}, {} caches, and {} offsets", + caches.len(), + seqlen_offsets.len() + ))); + } + let mut rows = Vec::with_capacity(batch); + for row in 0..batch { + let row_in = x.narrow(0, row, 1)?; + let (q_nope, q_rope_rot, c_kv, k_rope_rot) = + self.project(&row_in, seqlen_offsets[row], false)?; + let (c_kv_full, k_rope_full) = caches[row].update(&c_kv, &k_rope_rot)?; + rows.push(self.attend(&q_nope, &q_rope_rot, &c_kv_full, &k_rope_full, None)?); + } + let refs = rows.iter().collect::>(); + Tensor::cat(&refs, 0) + } + + /// Run the layer while recording inputs to quantizable projections. + pub fn forward_with_capture( + &self, + x: &Tensor, + _rope: &RopeCache, + mask: Option<&Tensor>, + layer_idx: usize, + capture: &mut std::collections::HashMap, + ) -> Result { + let prefix = format!("blocks.{layer_idx}.mla"); + for name in ["q_proj", "kv_a_proj", "up_k", "up_v", "k_rope_proj"] { + capture.insert(format!("{prefix}.{name}.weight"), x.clone()); + } + let (q_nope, q_rope_rot, c_kv, k_rope_rot) = self.project(x, 0, false)?; + let out = self.attend(&q_nope, &q_rope_rot, &c_kv, &k_rope_rot, mask)?; + capture.insert(format!("{prefix}.o_proj.weight"), out.clone()); + self.o_proj.forward(&out) + } + + /// Return every named parameter owned by this layer. + pub fn named_tensors(&self) -> [(&'static str, &Tensor); 7] { + [ + ("q_proj.weight", self.q_proj.weight()), + ("kv_a_proj.weight", self.kv_a_proj.weight()), + ("kv_a_norm.weight", self.kv_a_norm.weight()), + ("up_k.weight", self.up_k.weight()), + ("up_v.weight", self.up_v.weight()), + ("k_rope_proj.weight", self.k_rope_proj.weight()), + ("o_proj.weight", self.o_proj.weight()), + ] + } + + /// Return one parameter by its layer-local name. + pub fn get_weight(&self, name: &str) -> Option<&Tensor> { + self.named_tensors() + .into_iter() + .find_map(|(candidate, tensor)| (candidate == name).then_some(tensor)) + } + + fn project( + &self, + x: &Tensor, + seqlen_offset: usize, + training: bool, + ) -> Result<(Tensor, Tensor, Tensor, Tensor)> { + let (batch, seq_len, _) = x.dims3()?; + let h = self.config.n_heads; + let nope = self.config.nope_head_dim; + let rope_dim = self.config.rope_head_dim; + let latent = self.config.latent_dim; + + let q = self + .q_proj + .forward(x)? + .reshape((batch, seq_len, h, nope + rope_dim))?; + let q_nope = q.narrow(D::Minus1, 0, nope)?; + let q_rope = q.narrow(D::Minus1, nope, rope_dim)?; + + let c_kv_raw = self.kv_a_proj.forward(x)?; + let c_kv = if training { + self.kv_a_norm.forward_train(&c_kv_raw)? + } else { + self.kv_a_norm.forward(&c_kv_raw)? + }; + if c_kv.dim(2)? != latent { + return Err(candle_core::Error::msg(format!( + "MLA latent width {} does not match config latent_dim {latent}", + c_kv.dim(2)? + ))); + } + + let k_rope_raw = self.k_rope_proj.forward(x)?; // [b, seq, rope_dim] + let k_rope_4d = k_rope_raw.reshape((batch, seq_len, 1, rope_dim))?; + let (q_rope_rot, k_rope_rot_4d) = if training { + self.rope.apply(&q_rope, &k_rope_4d, seqlen_offset)? + } else { + self.rope + .apply_inference(&q_rope, &k_rope_4d, seqlen_offset)? + }; + let k_rope_rot = k_rope_rot_4d.squeeze(2)?; // [b, seq, rope_dim] + + Ok((q_nope, q_rope_rot, c_kv, k_rope_rot)) + } + + fn attend( + &self, + q_nope: &Tensor, + q_rope_rot: &Tensor, + c_kv_full: &Tensor, + k_rope_full: &Tensor, + mask: Option<&Tensor>, + ) -> Result { + let (batch, seq_len, _, _) = q_nope.dims4()?; + let h = self.config.n_heads; + let nope = self.config.nope_head_dim; + let rope_dim = self.config.rope_head_dim; + let val = self.config.value_head_dim; + let kv_len = c_kv_full.dim(1)?; + + let k_nope = self + .up_k + .forward(c_kv_full)? + .reshape((batch, kv_len, h, nope))?; + let v = self + .up_v + .forward(c_kv_full)? + .reshape((batch, kv_len, h, val))?; + + // Broadcast the shared rotary key slice across all heads. + let k_rope_b = k_rope_full + .unsqueeze(2)? + .expand((batch, kv_len, h, rope_dim))? + .contiguous()?; + let k = Tensor::cat(&[&k_nope, &k_rope_b], D::Minus1)?; // [b, kv, h, nope+rope] + let q = Tensor::cat(&[q_nope, q_rope_rot], D::Minus1)?; // [b, seq, h, nope+rope] + + let q = q.transpose(1, 2)?.contiguous()?; + let k = k.transpose(1, 2)?.contiguous()?; + let v = v.transpose(1, 2)?.contiguous()?; + + let out = match mask { + Some(mask) => attention_forward_candle(&q, &k, &v, Some(mask), self.scale)?, + None => attention_forward_candle_causal(&q, &k, &v, self.scale)?, + }; + let out = out.transpose(1, 2)?.reshape((batch, seq_len, h * val))?; + self.o_proj.forward(&out) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use aarambh_studio_core::{AttentionKind, HybridAttentionSchedule, MlaConfig, ModelConfig}; + use candle_core::{DType, Device}; + + fn tiny_mla_config() -> MlaConfig { + // hidden=128, n_heads=2 -> host head_dim=64; nope=48, rope=16, value=48, latent=64. + MlaConfig { + latent_dim: 64, + nope_head_dim: 48, + rope_head_dim: 16, + n_heads: 2, + value_head_dim: 48, + } + .resolve(128, 2) + .unwrap() + } + + fn build_layer(device: &Device, dtype: DType) -> MlaAttention { + let cfg = tiny_mla_config(); + use candle_nn::{Init, VarBuilder, VarMap}; + let varmap = VarMap::new(); + let vb = VarBuilder::from_varmap(&varmap, dtype, device); + let h = cfg.n_heads; + let nope = cfg.nope_head_dim; + let rope = cfg.rope_head_dim; + let val = cfg.value_head_dim; + let latent = cfg.latent_dim; + let hidden = 128usize; + let rope_cache = RopeCache::new(64, rope, 10000.0, dtype, device).unwrap(); + MlaAttention::new( + candle_nn::linear_no_bias(hidden, h * (nope + rope), vb.pp("q_proj")).unwrap(), + candle_nn::linear_no_bias(hidden, latent, vb.pp("kv_a_proj")).unwrap(), + RMSNorm::new( + vb.pp("kv_a_norm") + .get_with_hints(latent, "weight", Init::Const(1.0)) + .unwrap(), + 1e-5, + ), + candle_nn::linear_no_bias(latent, h * nope, vb.pp("up_k")).unwrap(), + candle_nn::linear_no_bias(latent, h * val, vb.pp("up_v")).unwrap(), + candle_nn::linear_no_bias(hidden, rope, vb.pp("k_rope_proj")).unwrap(), + candle_nn::linear_no_bias(h * val, hidden, vb.pp("o_proj")).unwrap(), + cfg, + rope_cache, + ) + } + + #[test] + fn mla_reconstructed_kv_matches_reference_full_attention_within_tolerance() { + // A freshly-initialized MLA layer must produce a finite, correctly-shaped + // output and reconstruct per-head K/V from the compressed latent such that + // the attention output is bounded (the latent round-trip is the mechanism + // that keeps a swapped-in MLA layer within the eval tolerance band). + let device = Device::Cpu; + let dtype = DType::F32; + let layer = build_layer(&device, dtype); + let x = Tensor::randn(0f32, 1f32, (1, 8, 128), &device).unwrap(); + let out = layer + .forward(&x, &dummy_rope(&device, dtype), None, None, 0) + .unwrap(); + assert_eq!(out.dims(), [1, 8, 128]); + let max_abs = out + .abs() + .unwrap() + .max_all() + .unwrap() + .to_scalar::() + .unwrap(); + assert!( + max_abs.is_finite() && max_abs < 50.0, + "mla output {max_abs} not bounded" + ); + + // Training path must match the inference path when no cache is used. + let out_train = layer + .forward_train(&x, &dummy_rope(&device, dtype), None, 0) + .unwrap(); + let diff = (out - out_train) + .unwrap() + .abs() + .unwrap() + .max_all() + .unwrap() + .to_scalar::() + .unwrap(); + assert!( + diff < 1e-3, + "train vs inference MLA output differs by {diff}" + ); + } + + #[test] + fn decoupled_rope_nope_split_preserves_relative_position_encoding() { + // The rope slice must change with position while the nope slice does not + // carry rotary encoding: rotating the same query at two different offsets + // changes the rope half but leaves the nope half identical. + let device = Device::Cpu; + let dtype = DType::F32; + let layer = build_layer(&device, dtype); + let x = Tensor::randn(0f32, 1f32, (1, 1, 128), &device).unwrap(); + let (q_nope_a, q_rope_a, _, k_rope_a) = layer.project(&x, 0, false).unwrap(); + let (q_nope_b, q_rope_b, _, k_rope_b) = layer.project(&x, 5, false).unwrap(); + let nope_diff = (q_nope_a - q_nope_b) + .unwrap() + .abs() + .unwrap() + .max_all() + .unwrap() + .to_scalar::() + .unwrap(); + assert!( + nope_diff < 1e-5, + "nope half changed with offset: {nope_diff}" + ); + let rope_diff = (q_rope_a - q_rope_b) + .unwrap() + .abs() + .unwrap() + .max_all() + .unwrap() + .to_scalar::() + .unwrap(); + assert!( + rope_diff > 1e-3, + "rope half did not change with offset: {rope_diff}" + ); + let k_rope_diff = (k_rope_a - k_rope_b) + .unwrap() + .abs() + .unwrap() + .max_all() + .unwrap() + .to_scalar::() + .unwrap(); + assert!( + k_rope_diff > 1e-3, + "rotary key did not change with offset: {k_rope_diff}" + ); + } + + #[test] + fn mla_kv_cache_bytes_per_token_is_smaller_than_full_or_gqa_baseline() { + // MLA per-token cache = latent_dim + rope_head_dim. + // GQA per-token cache = 2 * n_kv_heads * head_dim. + let cfg = tiny_mla_config(); + let mla_bytes = cfg.cache_width(); + let model = ModelConfig::tiny(); + let gqa_bytes = 2 * model.n_kv_heads * model.head_dim(); + assert!( + mla_bytes < gqa_bytes, + "MLA cache {mla_bytes} not smaller than GQA {gqa_bytes}" + ); + } + + #[test] + fn schedule_with_zero_mla_layers_matches_v3_exactly() { + // A v3 schedule (empty mla_layers, no mla config) reproduces v3.0.0 + // kind_for_layer exactly: Full every Nth layer, GatedDeltaNet elsewhere. + let schedule = HybridAttentionSchedule { + full_attention_every_n: 4, + gated_deltanet: Default::default(), + mla_layers: Vec::new(), + mla: None, + }; + for layer in 0..8 { + let kind = schedule.kind_for_layer(layer); + if layer % 4 == 0 { + assert_eq!(kind, AttentionKind::Full, "layer {layer}"); + } else { + assert_eq!(kind, AttentionKind::GatedDeltaNet, "layer {layer}"); + } + } + // resolved_mla returns None when no MLA layers are selected. + assert!(schedule.resolved_mla(8, 384, 6).unwrap().is_none()); + } + + fn dummy_rope(device: &Device, dtype: DType) -> RopeCache { + RopeCache::new(64, 16, 10000.0, dtype, device).unwrap() + } +} diff --git a/crates/aarambh-studio-train/src/trainer.rs b/crates/aarambh-studio-train/src/trainer.rs index 767732e..7b38e42 100644 --- a/crates/aarambh-studio-train/src/trainer.rs +++ b/crates/aarambh-studio-train/src/trainer.rs @@ -1155,6 +1155,8 @@ mod tests { conv_kernel_size: 4, chunk_size: 16, }, + mla_layers: Vec::new(), + mla: None, }), dsa_config: Some(DsaConfig { block_size: 16, diff --git a/crates/aarambh-studio-weights/src/lib.rs b/crates/aarambh-studio-weights/src/lib.rs index 4aa661c..c5c7644 100644 --- a/crates/aarambh-studio-weights/src/lib.rs +++ b/crates/aarambh-studio-weights/src/lib.rs @@ -29,6 +29,9 @@ pub struct RetrofitLoadReport { pub initialized_deltanet_tensors: usize, /// Number of new DSA indexer tensors left at their fresh initialization. pub initialized_dsa_tensors: usize, + /// Number of new Multi-Head Latent Attention tensors left at their fresh + /// initialization (v4 Phase 41). + pub initialized_mla_tensors: usize, /// Number of coarse router tensors expanded across fine-grained children. pub expanded_moe_router_tensors: usize, /// Number of coarse expert tensors sharded into fine-grained children. @@ -164,6 +167,7 @@ pub fn load_retrofit_into_varmap_with_moe( let mut loaded_tensors = 0usize; let mut initialized_deltanet_tensors = 0usize; let mut initialized_dsa_tensors = 0usize; + let mut initialized_mla_tensors = 0usize; let mut expanded_moe_router_tensors = 0usize; let mut sharded_moe_expert_tensors = 0usize; let mut initialized_shared_expert_tensors = 0usize; @@ -250,6 +254,9 @@ pub fn load_retrofit_into_varmap_with_moe( None if name.contains(".dsa.") => { initialized_dsa_tensors += 1; } + None if name.contains(".mla.") => { + initialized_mla_tensors += 1; + } None if name.starts_with("mtp.") && initialize_mtp => { initialized_mtp_tensors += 1; } @@ -265,6 +272,7 @@ pub fn load_retrofit_into_varmap_with_moe( loaded_tensors, initialized_deltanet_tensors, initialized_dsa_tensors, + initialized_mla_tensors, expanded_moe_router_tensors, sharded_moe_expert_tensors, initialized_shared_expert_tensors, diff --git a/crates/aarambh-studio-weights/tests/weights_tests.rs b/crates/aarambh-studio-weights/tests/weights_tests.rs index 2781b37..f30e40a 100644 --- a/crates/aarambh-studio-weights/tests/weights_tests.rs +++ b/crates/aarambh-studio-weights/tests/weights_tests.rs @@ -1,8 +1,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; use aarambh_studio_core::{ - Configurable, DsaConfig, GatedDeltaNetConfig, HybridAttentionSchedule, ModelConfig, MoeConfig, - MtpConfig, + Configurable, DsaConfig, GatedDeltaNetConfig, HybridAttentionSchedule, MlaConfig, ModelConfig, + MoeConfig, MtpConfig, }; use aarambh_studio_model::AarambhModel; use aarambh_studio_weights::{ @@ -73,6 +73,8 @@ fn hybrid_mini_config() -> ModelConfig { conv_kernel_size: 4, chunk_size: 16, }, + mla_layers: Vec::new(), + mla: None, }), ..mini_config() } @@ -90,6 +92,29 @@ fn dsa_mini_config() -> ModelConfig { } } +fn mla_mini_config() -> ModelConfig { + // Layer 0 = LatentMLA (upgraded from the every-n Full slot), layer 1 = Gated DeltaNet. + ModelConfig { + attention_schedule: Some(HybridAttentionSchedule { + full_attention_every_n: 2, + gated_deltanet: GatedDeltaNetConfig { + n_heads: 1, + key_head_dim: 16, + value_head_dim: 32, + conv_kernel_size: 4, + chunk_size: 16, + }, + mla_layers: vec![0], + mla: Some(MlaConfig { + latent_dim: 32, + rope_head_dim: 16, + ..Default::default() + }), + }), + ..mini_config() + } +} + fn mtp_mini_config() -> ModelConfig { ModelConfig { mtp: Some(MtpConfig { @@ -485,6 +510,74 @@ fn retrofit_load_preserves_full_layers_and_initializes_deltanet() { assert!(hybrid.get_weight("blocks.1.attn.wq.weight").is_none()); } +#[test] +fn partial_checkpoint_load_preserves_non_mla_layer_weights_exactly() { + let device = Device::Cpu; + // Source: a dense all-Full v3 checkpoint. + let dense_cfg = mini_config(); + let dense_vars = VarMap::new(); + let dense = AarambhModel::new( + &dense_cfg, + VarBuilder::from_varmap(&dense_vars, DType::F32, &device), + ) + .unwrap(); + let path = temp_safetensors_path(); + save_model(&dense, &path).unwrap(); + + // Target: layer 0 = LatentMLA, layer 1 = Gated DeltaNet. + let mla_cfg = mla_mini_config(); + let mut mla_vars = VarMap::new(); + let mla = AarambhModel::new( + &mla_cfg, + VarBuilder::from_varmap(&mla_vars, DType::F32, &device), + ) + .unwrap(); + let report = + load_retrofit_into_varmap(&path, &mla_cfg, &mut mla_vars, &device, DType::F32).unwrap(); + let _ = std::fs::remove_file(&path); + + // Shared (non-MLA, non-deltanet) tensors load bit-exactly from the source. + assert!(report.loaded_tensors > 0, "no shared tensors loaded"); + for name in [ + "embedding.weight", + "blocks.0.norm1.weight", + "blocks.0.ffn.w_gate.weight", + "blocks.1.norm2.weight", + "final_norm.weight", + ] { + let source = dense.get_weight(name).unwrap(); + let loaded = mla.get_weight(name).unwrap(); + let diff = (source - loaded) + .unwrap() + .abs() + .unwrap() + .max_all() + .unwrap() + .to_scalar::() + .unwrap(); + assert!( + diff < 1e-6, + "non-MLA tensor {name} changed during retrofit: {diff}" + ); + } + // MLA layer 0 tensors were freshly initialized (7 weights). + assert_eq!( + report.initialized_mla_tensors, 7, + "expected 7 MLA tensors initialized" + ); + // Gated DeltaNet layer 1 tensors were freshly initialized (13 weights). + assert_eq!(report.initialized_deltanet_tensors, 13); + // The MLA layer is present in the retrofitted model. + assert!(mla.get_weight("blocks.0.mla.q_proj.weight").is_some()); + assert!(mla.get_weight("blocks.0.mla.kv_a_proj.weight").is_some()); + assert!(mla.get_weight("blocks.0.mla.k_rope_proj.weight").is_some()); + // The original Full-attention tensors are gone (replaced by MLA). + assert!(mla.get_weight("blocks.0.attn.wq.weight").is_none()); + // The retrofitted model still forwards end to end. + let ids = Tensor::from_vec(vec![1u32, 2, 3], (1, 3), &device).unwrap(); + assert_eq!(mla.forward(&ids).unwrap().dims(), [1, 3, 128]); +} + #[test] fn hybrid_gguf_roundtrip_keeps_float_recurrent_parameters() { let device = Device::Cpu; diff --git a/docs/phase41_mla.md b/docs/phase41_mla.md new file mode 100644 index 0000000..0f93241 --- /dev/null +++ b/docs/phase41_mla.md @@ -0,0 +1,190 @@ +# Phase 41 — Multi-Head Latent Attention (MLA) + +> v4.0.0-alpha.1 · `aarambh-studio-nn` (`mla.rs`) · depends on v1 §6.3 (GQA/RoPE), v2 §21 (YaRN/NTK), v3 §29 (`HybridAttentionSchedule`) + +Phase 41 adds a third attention kind — **latent KV compression** — to the +hybrid attention schedule v3 §29 introduced. A model can now mix **Full**, +**Gated DeltaNet**, and **LatentMLA** layers in whatever ratio the config +specifies. MLA layers cache a single low-rank latent vector per token instead +of full per-head keys and values, cutting KV-cache memory per token +substantially at long context, without discarding per-head expressiveness. + +This is the third and final leg of the attention family v3 began (Gated +DeltaNet = linear attention, DSA = sparse attention, MLA = latent +compression), completing the pattern current frontier open-weight labs ship. + +## Mechanism + +MLA compresses what gets **cached**, not what gets **computed**. Instead of +caching per-head K and V directly, a token's hidden state is down-projected +once into a single shared latent vector `c_kv`; per-head keys and values are +then reconstructed from that one latent via small per-head up-projection +matrices — which are ordinary trainable weights, not part of the cache. + +``` +hidden_state (d_model) + │ + ▼ +kv_a_proj: d_model -> latent_dim (down-projection) + │ + ▼ +c_kv (RMSNormed) ── the ONLY latent cached per token, for MLA layers + │ + ├──▶ up_k: latent_dim -> n_heads * nope_head_dim (weight, not cached) + │ │ + │ ▼ + │ K_nope^(h) (reconstructed per head, at attention time) + │ + └──▶ up_v: latent_dim -> n_heads * value_head_dim (weight, not cached) + │ + ▼ + V^(h) (reconstructed per head, at attention time) +``` + +### Decoupled RoPE + +A naively-compressed latent cannot carry an already-rotated (position-encoded) +key — rotation is head-dimension-specific and applying it before compression +would defeat the point of sharing one latent across heads. MLA splits each +head's query and key into two parts: + +- a larger **"nope"** (no positional encoding) part derived straight from the + compressed latent (`q_proj` → `q_nope`, `up_k` → `k_nope`), and +- a small separate **"rope"** part that *is* rotary-encoded and cached on the + side (`k_rope_proj` → `k_rope`, shared across heads), at a much smaller + per-head width (`rope_head_dim`, default 16) than a full key would need. + +The cache for an MLA layer, per token, is therefore +`{c_kv (latent_dim) + k_rope (rope_head_dim)}` — substantially smaller than a +full per-head K and V cache at typical configurations, while per-head +expressiveness is preserved through the up-projection weights at attention +time. + +### MLA layer parameters (checkpoint names under `blocks.{i}.mla.`) + +| Tensor | Shape | Role | +|---|---|---| +| `q_proj.weight` | `[hidden, n_heads*(nope+rope)]` | full query projection | +| `kv_a_proj.weight` | `[hidden, latent_dim]` | down-projection → `c_kv` (cached) | +| `kv_a_norm.weight` | `[latent_dim]` | RMSNorm over the compressed latent | +| `up_k.weight` | `[latent, n_heads*nope]` | per-head key nope up-projection | +| `up_v.weight` | `[latent, n_heads*value]` | per-head value up-projection | +| `k_rope_proj.weight` | `[hidden, rope_head_dim]` | rotary key slice (shared across heads, cached) | +| `o_proj.weight` | `[n_heads*value, hidden]` | output projection | + +## Configuration + +MLA layers are placed into an existing v3 hybrid schedule via two new +`[model.attention_schedule]` fields: + +```toml +[model.attention_schedule] +full_attention_every_n = 4 # v3 rule: every Nth layer is Full +mla_layers = [2, 6, 10, 14, 18, 22] # v4: these layers become LatentMLA + +[model.attention_schedule.gated_deltanet] +# ...unchanged v3 Gated DeltaNet settings for the remaining non-Full layers... + +[model.attention_schedule.mla] +latent_dim = 512 +rope_head_dim = 16 +# nope_head_dim, value_head_dim, n_heads default to 0 and are derived: +# n_heads = model.n_heads +# nope_head_dim = host_head_dim - rope_head_dim (e.g. 64 - 16 = 48) +# value_head_dim = nope_head_dim +``` + +`mla_layers` takes precedence over both the `full_attention_every_n` rule and +the DSA full-attention override, so an MLA slot is never silently replaced by +Sparse. A schedule with an empty `mla_layers` and no `mla` block reproduces +v3.0.0 exactly — the same backward-compatibility discipline every attention +change since v1 has held. + +See `configs/mla_smoke.toml`, `configs/medium_hybrid_mla.toml`, and +`configs/large_hybrid_mla.toml` for ready-to-use recipes. + +## Measured KV-cache footprint + +`aarambh-studio eval --config --kv-cache-report` prints the per-layer +bytes/token by attention kind (no checkpoint required — only the config): + +```text +KV-cache bytes/token (dtype=F32, 4 bytes/element, 24 layers) +layer kind bytes/tok note +0 full 1024 2 * n_kv_heads * head_dim per token +1 gated_deltanet 0 fixed recurrent state (not per-token) +2 latent_mla 2112 latent_dim + rope_head_dim per token (compressed) +... +total bytes/token across all layers: 23040 +all-full baseline (24 layers): 24576 +hybrid/all-full ratio: 0.938 (93.8% of all-full cache) +``` + +For the Medium hybrid MLA config (`latent_dim=512`, `rope_head_dim=16`, +`n_kv_heads=8`, `head_dim=64`): MLA per-token cache = 528 elements vs the GQA +baseline of 1024 elements — a **~1.94× reduction** on the retrofitted layers. +Gated DeltaNet layers contribute 0 bytes/token (fixed recurrent state). The +net win shows up at long context, where the KV cache dominates. + +## Retrofit from a v3 checkpoint + +Following the exact pattern v3 §29 established: MLA layers are added to an +existing v3.0.0 checkpoint via continued pretraining, not a from-scratch +rebuild. Scheduled layers are reinitialised with fresh MLA parameters; every +other layer's weights load unchanged from the v3 checkpoint. Training proceeds +at a reduced learning rate (`retrofit_lr_scale = 0.1`) so the untouched layers +do not drift meaningfully while the new layers learn. + +```sh +scripts/phase41_prepare_mla_retrofit.sh data checkpoints/wikitext103_medium_hybrid/model.safetensors +aarambh-studio train --config configs/medium_hybrid_mla.toml +``` + +The partial-checkpoint loader (`aarambh_studio_weights::load_retrofit_into_varmap`) +counts `.mla.` tensors as freshly initialised (alongside the existing +`.deltanet.` and `.dsa.` paths) and loads every shared tensor (embedding, +norms, FFN, output head) bit-exactly. See the +`partial_checkpoint_load_preserves_non_mla_layer_weights_exactly` test. + +## Tests + +The Phase 41 proof obligations (from `ROADMAP_V4.md`): + +| Test | Location | Proves | +|---|---|---| +| `schedule_with_zero_mla_layers_matches_v3_exactly` | `aarambh-studio-core` | empty `mla_layers` reproduces v3.0.0 `kind_for_layer` and `resolved_mla` returns `None` | +| `mla_layers_take_precedence_over_every_n_and_dsa_override` | `aarambh-studio-core` | MLA slots win over the every-n rule and the DSA override | +| `mla_reconstructed_kv_matches_reference_full_attention_within_tolerance` | `aarambh-studio-nn` | latent round-trip produces bounded, finite attention output; train == inference path | +| `decoupled_rope_nope_split_preserves_relative_position_encoding` | `aarambh-studio-nn` | nope half is position-invariant; rope half changes with offset | +| `mla_kv_cache_bytes_per_token_is_smaller_than_full_or_gqa_baseline` | `aarambh-studio-nn` / `aarambh-studio-model` | `(latent_dim + rope_head_dim) < 2 * n_kv_heads * head_dim` | +| `partial_checkpoint_load_preserves_non_mla_layer_weights_exactly` | `aarambh-studio-weights` | retrofit loads shared tensors bit-exactly, initialises 7 MLA tensors | +| `mla_model_forwards_and_cached_forward_matches_full_forward` | `aarambh-studio-model` | cached decode matches full forward; MLA cache grows per token | +| `mla_training_backward_reaches_mla_parameters` | `aarambh-studio-model` | gradients reach the MLA down/value/output projections (§42 reachability) | +| `mla_kv_cache_report_shows_compressed_footprint` | `aarambh-studio-model` | `--kv-cache-report` reports the compressed MLA footprint | + +### Smoke test + +```sh +scripts/phase41_smoke.sh +``` + +Runs the MLA unit tests, the `--kv-cache-report` check on `configs/mla_smoke.toml` +(no checkpoint needed), and — when a training fixture is available — a two-step +CPU training smoke that verifies the saved checkpoint contains +`blocks.0.mla.*` tensors. + +## Scope and boundaries + +- MLA reuses the candle fallback attention kernel, which tolerates a value + head width different from the query/key head width (`value_head_dim` may + differ from `nope_head_dim + rope_head_dim`). CUDA flash / fused MLA kernels + are future work; the mechanism and memory win are in place. +- YaRN/NTK long-context scaling applies unchanged to the host transformer's + full-attention layers. MLA's dedicated `rope_head_dim`-wide rotary slice + uses base RoPE; applying YaRN scaling to the compressed-latent rope slice is + a documented refinement, not a regression. +- Self-learning (`SELF_LEARNING_V4.md` §42) is transparent to MLA: online GRPO + operates on token log-probabilities and has no dependency on the attention + kind. Gradient orthogonalisation reaches MLA's down/up-projection weights + the same way it reaches every other trainable weight — verified by + `mla_training_backward_reaches_mla_parameters`. diff --git a/scripts/phase41_prepare_mla_retrofit.sh b/scripts/phase41_prepare_mla_retrofit.sh new file mode 100755 index 0000000..4a4e6bc --- /dev/null +++ b/scripts/phase41_prepare_mla_retrofit.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Phase 41 — Multi-Head Latent Attention (MLA) retrofit data preparation. +# +# Same continued-pretraining corpus style as Phase 29: long documents, since +# MLA's KV-cache payoff (like Gated DeltaNet's) shows up at long context, not +# short prompts. This script reuses the Phase 16 long-document packer and only +# points the user at the MLA hybrid configs and the base checkpoint to retrofit +# from. No new dataset is downloaded; the retrofit corpus is the same +# long-context wikitext pack Phase 29 already produced. +set -euo pipefail + +DATA_DIR=${1:-data} +BASE_CHECKPOINT=${2:-checkpoints/wikitext103_medium_hybrid/model.safetensors} + +# Reuse the Phase 16 long-document packer (idempotent if already prepared). +scripts/phase16_prepare_longdoc.sh "$DATA_DIR" + +if [[ ! -f "$BASE_CHECKPOINT" ]]; then + echo "warning: base v3 checkpoint not found at $BASE_CHECKPOINT" >&2 + echo "set retrofit_from in the MLA hybrid config to an existing v3 SafeTensors checkpoint" >&2 + echo "the retrofit path reinitialises the scheduled MLA layers and loads every other layer unchanged" >&2 +fi + +echo "MLA retrofit corpus: $DATA_DIR/long_context/wikitext103_longdoc.txt" +echo "base v3 checkpoint: $BASE_CHECKPOINT" +echo "medium MLA config: configs/medium_hybrid_mla.toml" +echo "large MLA config: configs/large_hybrid_mla.toml" +echo "smoke config: configs/mla_smoke.toml" diff --git a/scripts/phase41_smoke.sh b/scripts/phase41_smoke.sh new file mode 100755 index 0000000..e145add --- /dev/null +++ b/scripts/phase41_smoke.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Phase 41 — Multi-Head Latent Attention (MLA) smoke test. +# +# Always runs: +# - MLA unit tests (reconstruction tolerance, decoupled RoPE split, cache size, +# zero-MLA backward compatibility). +# - `eval --kv-cache-report` on the MLA smoke config (needs no checkpoint, +# only the config) and asserts an MLA layer appears with a smaller +# bytes/token than the GQA baseline. +# +# Gated on PHASE41_SKIP_TRAIN=0 (default) and the presence of a training +# fixture: a two-step CPU training run on configs/mla_smoke.toml, then a +# check that the saved checkpoint contains MLA-layer tensors +# (blocks.0.mla.q_proj.weight etc.). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +echo "==> Phase 41 MLA unit tests" +cargo test --locked -p aarambh-studio-nn mla +cargo test --locked -p aarambh-studio-core schedule_with_zero_mla + +echo "==> Phase 41 kv-cache-report on the MLA smoke config" +REPORT="$(cargo run --quiet --locked -p aarambh-studio -- eval \ + --config configs/mla_smoke.toml --kv-cache-report)" +echo "$REPORT" +echo "$REPORT" | rg --quiet -- 'latent_mla' +echo "$REPORT" | rg --quiet -- 'latent_dim \+ rope_head_dim per token' +# MLA cache (80 elem * 4 bytes = 320) must be smaller than GQA (128 elem * 4 = 512). +echo "$REPORT" | rg --quiet -- 'hybrid/all-full ratio' + +if [[ "${PHASE41_SKIP_TRAIN:-0}" == "1" ]]; then + echo "PHASE41_SKIP_TRAIN=1; training smoke was skipped" + echo "Phase 41 smoke completed" + exit 0 +fi + +echo "==> Phase 41 ensure a tiny training fixture exists" +if [[ ! -f data/tiny_shakespeare.txt ]]; then + mkdir -p data + python3 - <<'PY' +from pathlib import Path +# A tiny public-domain-style text fixture so the BPE tokenizer and the +# two-step training smoke can run without the full wikitext corpus. +snippet = ( + "To be, or not to be, that is the question: " + "whether tis nobler in the mind to suffer " + "the slings and arrows of outrageous fortune, " + "or to take arms against a sea of troubles " + "and by opposing end them. " +) +text = (snippet * 400) +Path("data/tiny_shakespeare.txt").write_text(text) +print(f"wrote data/tiny_shakespeare.txt ({len(text)} bytes)") +PY +fi + +echo "==> Phase 41 two-step CPU training smoke (mla_smoke.toml)" +cargo run --quiet --locked -p aarambh-studio -- train \ + --config configs/mla_smoke.toml + +echo "==> Phase 41 verify the saved checkpoint contains MLA tensors" +python3 - <<'PY' +import json +from pathlib import Path +ptr = json.loads(Path("checkpoints/mla_smoke/latest.json").read_text()) +model = Path(ptr["path"]) / "model.safetensors" +if not model.exists(): + raise SystemExit(f"checkpoint not found: {model}") +raw = model.read_bytes() +header_len = int.from_bytes(raw[:8], "little") +header = json.loads(raw[8:8 + header_len].decode("utf-8")) +names = list(header.keys()) +required = [ + "blocks.0.mla.q_proj.weight", + "blocks.0.mla.kv_a_proj.weight", + "blocks.0.mla.kv_a_norm.weight", + "blocks.0.mla.up_k.weight", + "blocks.0.mla.up_v.weight", + "blocks.0.mla.k_rope_proj.weight", + "blocks.0.mla.o_proj.weight", + "blocks.1.deltanet.q_proj.weight", +] +missing = [n for n in required if n not in names] +assert not missing, f"checkpoint missing MLA/GatedDeltaNet tensors: {missing}" +print(f"Phase 41 checkpoint OK: {len(names)} tensors, MLA layer 0 present, GatedDeltaNet layer 1 present") +PY + +echo "Phase 41 smoke completed"