rtx-csm: Phase 12.1 — extend LoRA coverage q+v → full attn + MLP

Both backbones (FP csm_fork + Q8 csm_quantized) now expose 7 LoRA hooks
per layer: q/k/v/o on attention plus gate/up/down (Llama w1/w3/w2) on
the SwiGLU MLP. LoraConfig::default() still returns q+v only (backward
compat for existing trained adapters); LoraConfig::extended() returns
the full 7-module set. lora_train + lora_finetune_step take a
--extended-lora flag.

Verified end-to-end on Metal: injection across all 16 backbone layers
× 7 modules = 224 adapter Vars, 5.6M trainable params (~6.6× q+v alone,
still tiny vs the 1B base). Step-0 loss matches the q+v baseline
exactly (B=0 init is also a no-op for the new hooks). Forward + backward
+ AdamW + refresh_lora cycle runs without errors.

LoRA test suite: 9 pass (added config_extended_targets_full_attn_and_mlp);
full lib suite still 92/92.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-04-27 15:05:57 -07:00
co-authored by Claude Opus 4.7
parent a0dbe7caf4
commit 5423136bae
5 changed files with 360 additions and 111 deletions
+153 -61
View File
@@ -161,11 +161,15 @@ pub(crate) struct Attention {
k_proj: Linear,
v_proj: Linear,
o_proj: Linear,
/// Optional LoRA adapters for q_proj / v_proj — the literature recipe for
/// CSM-style backbones. None means inference-only; Some adds an additive
/// delta path. Inactive (B=0) at init so behavior is identical to base.
/// Optional LoRA adapters on the four attention projections. None means
/// inference-only; Some adds an additive delta path. Inactive (B=0) at
/// init so behavior is identical to base. `o_lora` corresponds to the
/// `output_proj` weight in safetensors (the field is named `o_proj` here
/// for brevity but the upstream key is `output_proj`).
pub(crate) q_lora: Option<crate::lora::LoraDelta>,
pub(crate) k_lora: Option<crate::lora::LoraDelta>,
pub(crate) v_lora: Option<crate::lora::LoraDelta>,
pub(crate) o_lora: Option<crate::lora::LoraDelta>,
rotary_emb: Arc<RotaryEmbedding>,
kv_cache: Option<(Tensor, Tensor)>,
num_heads: usize,
@@ -189,7 +193,9 @@ impl Attention {
v_proj,
o_proj,
q_lora: None,
k_lora: None,
v_lora: None,
o_lora: None,
rotary_emb,
kv_cache: None,
num_heads: cfg.num_heads,
@@ -229,6 +235,10 @@ impl Attention {
None => query_states,
};
let key_states = self.k_proj.forward(xs)?;
let key_states = match &self.k_lora {
Some(l) => (key_states + l.forward(xs)?)?,
None => key_states,
};
let value_states = self.v_proj.forward(xs)?;
let value_states = match &self.v_lora {
Some(l) => (value_states + l.forward(xs)?)?,
@@ -276,10 +286,14 @@ impl Attention {
let attn_weights = candle_nn::ops::softmax_last_dim(&attn_weights)?;
attn_weights.matmul(&value_states)?
};
let out = attn_output
let pre_o = attn_output
.transpose(1, 2)?
.reshape((b_sz, q_len, self.num_heads * self.head_dim))?
.apply(&self.o_proj)?;
.reshape((b_sz, q_len, self.num_heads * self.head_dim))?;
let out = self.o_proj.forward(&pre_o)?;
let out = match &self.o_lora {
Some(l) => (out + l.forward(&pre_o)?)?,
None => out,
};
if std::env::var("CSM_LORA_DEBUG").is_ok() {
eprintln!("Attn::forward returns: track_op={}", out.track_op());
}
@@ -292,10 +306,17 @@ impl Attention {
}
#[derive(Debug, Clone)]
struct Mlp {
pub(crate) struct Mlp {
w1: Linear,
w2: Linear,
w3: Linear,
/// Optional LoRA adapters on the SwiGLU MLP projections. CSM uses Llama's
/// `w1` / `w2` / `w3` naming where `w1` is the gate, `w3` is the up, and
/// `w2` is the down projection. Each is independently optional; B=0 init
/// makes them no-ops until trained.
pub(crate) gate_lora: Option<crate::lora::LoraDelta>,
pub(crate) up_lora: Option<crate::lora::LoraDelta>,
pub(crate) down_lora: Option<crate::lora::LoraDelta>,
}
impl Mlp {
@@ -303,15 +324,35 @@ impl Mlp {
let w1 = linear_b(cfg.embed_dim, cfg.intermediate_dim, false, vb.pp("w1"))?;
let w2 = linear_b(cfg.intermediate_dim, cfg.embed_dim, false, vb.pp("w2"))?;
let w3 = linear_b(cfg.embed_dim, cfg.intermediate_dim, false, vb.pp("w3"))?;
Ok(Self { w1, w2, w3 })
Ok(Self {
w1,
w2,
w3,
gate_lora: None,
up_lora: None,
down_lora: None,
})
}
}
impl Module for Mlp {
fn forward(&self, xs: &Tensor) -> Result<Tensor> {
let lhs = xs.apply(&self.w1)?.silu()?;
let rhs = xs.apply(&self.w3)?;
(lhs * rhs)?.apply(&self.w2)
let gate = self.w1.forward(xs)?;
let gate = match &self.gate_lora {
Some(l) => (gate + l.forward(xs)?)?,
None => gate,
};
let up = self.w3.forward(xs)?;
let up = match &self.up_lora {
Some(l) => (up + l.forward(xs)?)?,
None => up,
};
let mid = (gate.silu()? * up)?;
let down = self.w2.forward(&mid)?;
match &self.down_lora {
Some(l) => down + l.forward(&mid)?,
None => Ok(down),
}
}
}
@@ -320,7 +361,7 @@ pub(crate) struct Layer {
mlp_norm: RmsNorm,
sa_norm: RmsNorm,
pub(crate) attn: Attention,
mlp: Mlp,
pub(crate) mlp: Mlp,
}
impl Layer {
@@ -524,11 +565,17 @@ impl Model {
self.cfg_backbone.is_some()
}
/// Inject trainable LoRA adapters on backbone q/v projections per the
/// standard recipe (StyleSpeech / UtterTune / Koel-TTS): rank 8, alpha 16,
/// q_proj + v_proj only, backbone only (decoder + heads stay frozen).
/// Adapters are registered into `vm` for AdamW pickup; B is zero-initialized
/// so initial behavior is identical to the un-adapted base.
/// Inject trainable LoRA adapters on the backbone, with per-module
/// targeting controlled by `cfg.target_modules`. The classic recipe is
/// q+v only (rank 8, alpha 16); the extended recipe (Phase 12.1) covers
/// q, k, v, output_proj plus the SwiGLU MLP (w1/w2/w3) for higher capacity
/// when ~hours of training audio are available — this gives the adapter
/// real prosody control rather than just a thin attention nudge.
///
/// Decoder + heads stay frozen by construction (we only walk the backbone
/// layers; `cfg.exclude_patterns` is the additional safety net). Adapters
/// are registered into `vm` for AdamW pickup; B is zero-initialized so
/// initial behavior is identical to the un-adapted base.
pub fn add_lora_to_backbone(
&mut self,
cfg: &crate::lora::LoraConfig,
@@ -537,62 +584,107 @@ impl Model {
let backbone_cfg = LlamaConfig::from_flavor(self.config.backbone_flavor);
let head_dim = backbone_cfg.embed_dim / backbone_cfg.num_heads;
let kv_dim = backbone_cfg.num_kv_heads * head_dim;
let embed_dim = backbone_cfg.embed_dim;
let inter_dim = backbone_cfg.intermediate_dim;
let device = self.backbone.device.clone();
let dtype = self.backbone.dtype;
let mut q_count = 0usize;
let mut v_count = 0usize;
// Closure to keep each per-target block readable and uniform.
let make = |name: &str, in_dim: usize, out_dim: usize|
-> candle_core::Result<crate::lora::LoraDelta>
{
crate::lora::LoraDelta::new(
cfg.rank,
cfg.alpha as f64,
in_dim,
out_dim,
name,
vm,
&device,
dtype,
)
.map_err(|e| candle_core::Error::Msg(e.to_string()))
};
let mut counts = [0usize; 7]; // q, k, v, o, w1(gate), w3(up), w2(down)
for (i, layer) in self.backbone.layers.iter_mut().enumerate() {
// q_proj: in=embed_dim, out=embed_dim
if cfg.matches(&format!("backbone.layers.{i}.attn.q_proj.weight")) {
q_count += 1;
let q = crate::lora::LoraDelta::new(
cfg.rank,
cfg.alpha as f64,
backbone_cfg.embed_dim,
backbone_cfg.embed_dim,
&format!("backbone.layers.{i}.attn.q_proj"),
vm,
&device,
dtype,
)
.map_err(|e| candle_core::Error::Msg(e.to_string()))?;
layer.attn.q_lora = Some(q);
// attn.q_proj: embed_dimembed_dim
let key = format!("backbone.layers.{i}.attn.q_proj");
if cfg.matches(&format!("{key}.weight")) {
layer.attn.q_lora = Some(make(&key, embed_dim, embed_dim)?);
counts[0] += 1;
}
// v_proj: in=embed_dim, out=kv_dim (note non-square)
if cfg.matches(&format!("backbone.layers.{i}.attn.v_proj.weight")) {
v_count += 1;
let v = crate::lora::LoraDelta::new(
cfg.rank,
cfg.alpha as f64,
backbone_cfg.embed_dim,
kv_dim,
&format!("backbone.layers.{i}.attn.v_proj"),
vm,
&device,
dtype,
)
.map_err(|e| candle_core::Error::Msg(e.to_string()))?;
layer.attn.v_lora = Some(v);
// attn.k_proj: embed_dim → kv_dim
let key = format!("backbone.layers.{i}.attn.k_proj");
if cfg.matches(&format!("{key}.weight")) {
layer.attn.k_lora = Some(make(&key, embed_dim, kv_dim)?);
counts[1] += 1;
}
// attn.v_proj: embed_dim → kv_dim
let key = format!("backbone.layers.{i}.attn.v_proj");
if cfg.matches(&format!("{key}.weight")) {
layer.attn.v_lora = Some(make(&key, embed_dim, kv_dim)?);
counts[2] += 1;
}
// attn.output_proj: embed_dim → embed_dim. Note: safetensors key is
// `output_proj` (per upstream csm/torchtune); the struct field is
// named `o_proj` but the LoRA prefix follows the on-disk name so
// saved adapter files are inspectable.
let key = format!("backbone.layers.{i}.attn.output_proj");
if cfg.matches(&format!("{key}.weight")) {
layer.attn.o_lora = Some(make(&key, embed_dim, embed_dim)?);
counts[3] += 1;
}
// mlp.w1 (SwiGLU gate): embed_dim → intermediate_dim
let key = format!("backbone.layers.{i}.mlp.w1");
if cfg.matches(&format!("{key}.weight")) {
layer.mlp.gate_lora = Some(make(&key, embed_dim, inter_dim)?);
counts[4] += 1;
}
// mlp.w3 (SwiGLU up): embed_dim → intermediate_dim
let key = format!("backbone.layers.{i}.mlp.w3");
if cfg.matches(&format!("{key}.weight")) {
layer.mlp.up_lora = Some(make(&key, embed_dim, inter_dim)?);
counts[5] += 1;
}
// mlp.w2 (SwiGLU down): intermediate_dim → embed_dim
let key = format!("backbone.layers.{i}.mlp.w2");
if cfg.matches(&format!("{key}.weight")) {
layer.mlp.down_lora = Some(make(&key, inter_dim, embed_dim)?);
counts[6] += 1;
}
}
tracing::info!("LoRA injected into {q_count} q_proj and {v_count} v_proj backbone layers");
tracing::info!(
"LoRA injected: q={} k={} v={} o={} mlp_gate={} mlp_up={} mlp_down={}",
counts[0], counts[1], counts[2], counts[3], counts[4], counts[5], counts[6],
);
Ok(())
}
/// After an optimizer step, refresh the LoRA Tensor handles inside each
/// Attention from the VarMap so the next forward pass sees the updated
/// values. Required because LoraDelta holds plain Tensors (not Vars), and
/// candle's optimizer mutates the underlying Var storage in place.
/// Attention / Mlp from the VarMap so the next forward pass sees the
/// updated values. Required because LoraDelta holds plain Tensors (not
/// Vars), and candle's optimizer mutates the underlying Var storage
/// in place.
pub fn refresh_lora(&mut self, vm: &candle_nn::VarMap) -> candle_core::Result<()> {
for (i, layer) in self.backbone.layers.iter_mut().enumerate() {
if let Some(q) = layer.attn.q_lora.as_mut() {
q.refresh_from(vm, &format!("backbone.layers.{i}.attn.q_proj"))
.map_err(|e| candle_core::Error::Msg(e.to_string()))?;
}
if let Some(v) = layer.attn.v_lora.as_mut() {
v.refresh_from(vm, &format!("backbone.layers.{i}.attn.v_proj"))
.map_err(|e| candle_core::Error::Msg(e.to_string()))?;
}
let do_refresh = |slot: Option<&mut crate::lora::LoraDelta>, key: String|
-> candle_core::Result<()>
{
if let Some(d) = slot {
d.refresh_from(vm, &key)
.map_err(|e| candle_core::Error::Msg(e.to_string()))?;
}
Ok(())
};
do_refresh(layer.attn.q_lora.as_mut(), format!("backbone.layers.{i}.attn.q_proj"))?;
do_refresh(layer.attn.k_lora.as_mut(), format!("backbone.layers.{i}.attn.k_proj"))?;
do_refresh(layer.attn.v_lora.as_mut(), format!("backbone.layers.{i}.attn.v_proj"))?;
do_refresh(layer.attn.o_lora.as_mut(), format!("backbone.layers.{i}.attn.output_proj"))?;
do_refresh(layer.mlp.gate_lora.as_mut(), format!("backbone.layers.{i}.mlp.w1"))?;
do_refresh(layer.mlp.up_lora.as_mut(), format!("backbone.layers.{i}.mlp.w3"))?;
do_refresh(layer.mlp.down_lora.as_mut(), format!("backbone.layers.{i}.mlp.w2"))?;
}
Ok(())
}