Loading…
gathering yarn balls…
Cat Game Research
Frontier
Dev-log transmissions from the expedition

The Flat Loss Mystery: LoRA Training Diagnostics on Vulkan

Four controlled experiments reveal why our LoRA SFT loss stays at 2.08 regardless of KL, weight decay, learning rate, or rsLoRA scale — and what it teaches us about training metrics.

We ran four controlled experiments on our native Vulkan LoRA training pipeline, varying KL regularization, weight decay, learning rate, and rsLoRA scale. Every single run produced the same epoch-level loss to four decimal places. Here's what we found — and what it means for how you measure LoRA training progress.

The Setup

Our pi_train tool is a from-scratch LoRA SFT trainer built on top of llama.cpp's ggml optimizer. It runs entirely on Vulkan — no CUDA, no PyTorch, no Hugging Face Trainer. The target model is Qwen3.5-2B (F16), fine-tuned on 3,079 CatGame code and documentation conversations packed into 4096-token windows with 99.6% utilization.

The training pipeline includes:

228
LoRA Tensors
64
LoRA Rank
4096
Context Length
850
Tok/s (Vulkan)
8GB
GPU VRAM
3
Epochs

The Mystery

When we started tuning KL regularization, we noticed something strange. The epoch-level training loss was identical across all KL settings — 0.0, 0.01, 0.03, and 0.1 all produced train_loss=2.0829 for epoch 1, 2.0879 for epoch 2, and 2.0855 for epoch 3. To four decimal places. Every time.

The loss was so perfectly reproducible that we initially suspected a bug — maybe the LoRA wasn't being applied at all. But the MD5 checksums of the saved adapters were all different. The weights were changing. The loss just wasn't showing it.

The Experiments

We designed a controlled experiment matrix using the scientific method — changing one variable at a time while keeping everything else fixed:

ExpKLTMKLWDLRrsLoRAScaleE1E2E3Weights?
10.00.00.03e-4yes16.02.08292.08792.0855CHANGED
20.00.00.03e-4yes8.02.08292.08792.0855CHANGED
30.00.00.03e-4no2.02.08292.08792.0855CHANGED
40.00.10.03e-4yes16.02.08292.08792.0855CHANGED

Plus three prior runs with different KL and WD values at LR=1e-4 — all producing the same numbers. Seven experiments total, all identical.

What We Found

1
The LoRA weights ARE changing. MD5 checksums differ between initial and trained adapters in every experiment. The optimizer is doing its job — gradients flow, weights update, the adapter learns.
2
Per-category losses ARE changing. Some categories improve significantly: abilities drops from 1.94 to 1.42, pi from 1.87 to 1.70, rules from 2.18 to 1.87. But others regress: shaders rises from 1.76 to 2.09, specs from 1.83 to 1.90. The net average stays flat.
3
Validation losses are frozen.val_loss=2.0225 in every epoch of every experiment. The validation set is only 28 data points — too small to show the subtle per-category shifts.
4
The cumulative average is the culprit. The ggml_opt_result_loss() API returns a running cumulative average across all batches. With per-batch losses tightly clustered around 2.0–2.2, the average is mathematically pinned at ~2.08 regardless of individual batch variations.

KL Regularization: Three Approaches Tested

1. L2 Weight Pullback (Original)

Our first KL implementation was a post-hoc weight pullback: after each optimizer step, pull LoRA weights slightly toward their initial values.

theta -= lr * lambda * (theta - theta_0)
// Equivalent to adding 0.5 * lambda * mean(||theta - theta_0||^2) to loss

The problem: with lr=1e-4 and lambda=0.03, the pullback step is 3e-6 — moving weights by 0.0003% per step. Effectively zero. This is why all KL values produced identical results.

2. True Graph-Level KL (Attempted)

We tried injecting the KL gradient directly into ggml_opt_grad_acc() accumulators before the optimizer step. This would make the optimizer see CE_grad + KL_grad and apply momentum/clipping to the combined gradient.

Crash: gb_opt freed before callback
The ggml_opt_eval() function frees gb_opt after evaluation for non-static graphs, before the epoch callback fires. Calling ggml_opt_grad_acc() in the callback segfaults. A pre-eval hook would be needed in the external library — which is read-only in our architecture.

3. Target-Masked KL (TMKL)

Based on research from Mask the Target (arXiv:2605.29498), we implemented TMKL — the correct KL approach for LoRA SFT:

// At each supervised position:
// 1. Get base model distribution p_base (LoRA scale=0)
// 2. Get adapted model distribution p_adapted (LoRA scale=1)
// 3. Remove target token y from both, renormalize
// 4. KL(adapted || base) over non-target vocabulary
//
// L = CE + lambda * TMKL

TMKL preserves the base model's preferences over non-target tokens without opposing the cross-entropy gradient. Standard KL (and our L2 pullback) directly fights CE — the base model assigns low probability to target-domain tokens that CE must learn, so any KL pull toward the base model opposes learning.

Implementation Limitation
Full TMKL requires two forward passes per step (base + adapted) and graph-level gradient computation. Without modifying the external library, we can only compute TMKL as a post-hoc pullback — which has the same ineffectiveness problem as L2 KL at these scales. The infrastructure is in place; the graph-level integration is a roadmap item.

rsLoRA Scale Analysis

rsLoRA (rank-stabilized LoRA) adjusts the scaling factor from alpha/rank to alpha/sqrt(rank). With our defaults:

// Standard LoRA:  scale = alpha / rank = 128 / 64 = 2.0
// rsLoRA (alpha=2r): scale = 1024 / 64 = 16.0  (8x amplification!)
// rsLoRA (alpha=r):  scale = 64 / 64 = 1.0     (via stored alpha=64*sqrt(64)=512)

We tested all three scales (16.0, 8.0, 2.0). The loss was identical in all cases — but this doesn't mean scale doesn't matter. It means the loss metric can't distinguish between them. The actual weight updates and per-category effects may differ, but the cumulative average washes them out.

What This Teaches Us

Cumulative average loss is a misleading metric for LoRA training. It's mathematically pinned by the distribution of per-batch losses, not by the learning progress. Use per-category, per-batch, and held-out perplexity metrics instead.

Lessons for LoRA Practitioners

The Vulkan Training Pipeline

All experiments ran on our native Vulkan compute backend — no CUDA dependency. The pipeline achieves 850 tok/s on an 8GB GPU with 228 LoRA tensors across all model layers (attention, FFN, SSM, NextN). This is a from-scratch implementation: the forward pass, backward pass, gradient accumulation, optimizer, and telemetry are all CatGame-owned code, with only the base ggml graph engine as an external dependency.

850
Tok/s Peak
70%
GPU Utilization
6.1GB
VRAM Used
78°C
GPU Temp

Next Steps

Caveats and Limitations

← All transmissions