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

The Silent Killer: Unmasked Padding in LoRA Cross-Entropy Loss

How padding positions in fused cross-entropy loss caused progressive LoRA divergence on Qwen3.5-2B, and the masked CE fix that eliminated it.

The Symptom

We were training a LoRA adapter on Qwen3.5-2B — a hybrid model with 25 layers interleaving softmax attention and Gated DeltaNet (a state-space / linear attention variant). The architecture matters because it changes which layers get LoRA adapters and how gradients flow. But the bug we found wasn't in the architecture. It was in something far more mundane.

The training loss looked fine for the first epoch. Then it started climbing. By epoch 3, validation loss had gone from 3.64 to 4.47 — the model was getting worse with more training. The LoRA adapter drift was growing exponentially: 3.37 → 6.66 → 9.79. And the last batch of each epoch showed a bizarre loss spike above 7, sometimes 9.

3.64→4.47
Val loss (diverging)
9.79
LoRA drift (epoch 3)
>9
Last-batch loss spike
248K
Vocab size

The Investigation

We followed the scientific method: form a hypothesis, change one variable, measure, compare. The leading suspects were learning rate, gradient clipping, hybrid SSM gradients, KL regularization, and dataset packing. One by one, we tested them. None explained the divergence pattern.

Then we traced the loss computation path. The training loop uses ggml_cross_entropy_loss — a fused op that computes both forward loss and backward gradients in a single pass for numerical stability. The external implementation in ops.cpp revealed the truth:

// Forward: loss = -sum(log_softmax(logits) * labels) / nr
// Backward: grad = (softmax - labels) / nr * grad_loss
//
// nr = total number of rows (ALL positions, including padding)

The op divides by nr — the total number of positions, including padding. And the global g_pad_token_id? It was only used for accuracy accounting (counting correct predictions), not for loss masking. Padding positions had one-hot labels at the pad token index, meaning the model was being trained to predict pad_token at every padding position.

The root cause: Padding positions contributed to both loss and gradient. The model wasted capacity learning to predict a token that doesn't matter. The spurious gradient accumulated over epochs, causing progressive divergence. The last batch — often mostly padding — showed the highest loss because it had the most padding positions.

The Fix

The fix required two changes, both in CatGame-owned code (no external modifications):

1. Zero out pad_token labels

Before graph compute, read the pad_token row from the labels tensor. For each position where the pad_token label is 1.0 (indicating padding), zero it out. This removes the spurious "learn to predict pad_token" gradient signal. The remaining gradient for padding positions is softmax / n_valid — a benign uniform push.

2. Loss scale correction

The fused op divides by nr (total positions). After masking, we need division by n_valid (valid positions only). A runtime scalar input loss_scale = n_total / n_valid is multiplied with the CE loss in the graph:

// Graph: loss = ce_loss * loss_scale
//   ce_loss = -sum(log_softmax * labels_zeroed) / n_total
//   loss_scale = n_total / n_valid
//   => loss = -sum(log_softmax * labels_zeroed) / n_valid
//
// Backward: grad = (softmax - labels_zeroed) / n_valid
//   Valid positions:  grad = (softmax - one_hot) / n_valid  (CORRECT)
//   Padding positions: grad = softmax / n_valid              (benign)

The Results

We ran a controlled experiment: same dataset (276 conversations), same hyperparameters (lr=1e-6, rank=16, alpha=32, 3 epochs, skip_ssm=true), with and without the masked CE fix.

EpochUnmasked trainUnmasked valUnmasked driftMasked trainMasked valMasked drift
11.41253.64333.371.30261.11340.34
21.48173.91636.661.30711.11390.66
31.77034.46539.791.31861.11480.97
1.11
Val loss (stable)
0.97
LoRA drift (epoch 3)
0
Last-batch spikes
159
tok/s (unchanged)

The divergence was completely eliminated. Validation loss stayed flat at 1.11 across all three epochs. LoRA drift grew linearly (0.34 → 0.97) instead of exponentially (3.37 → 9.79). The last-batch loss spike disappeared entirely. And training throughput was unchanged at 159 tokens/second — the masking overhead is negligible.

Why It Was So Subtle

Three things made this bug hard to find:

Hybrid Architecture Lessons

Separate research confirmed that our skip_ssm=true setting is correct for Qwen3.5. The paper "Where Should LoRA Go?" (arXiv 2604.22127) studied Qwen3.5-0.8B and found that adapting the recurrent backbone is destructive in sequential hybrids (−14.8 pp on GSM8K), while attention-only LoRA outperforms full-model adaptation with 5–10× fewer trainable parameters.

The SSM/DeltaNet weights govern hidden state transitions. Even minor scale drift in these tensors destabilizes the recurrence, causing context collapse and repetitive looping at long sequences. Keeping LoRA adapters off the SSM layers avoids this failure mode entirely.

LR Sweep

With the masked CE fix in place, we swept the learning rate to find the new stable range:

LREp1 valEp2 valEp3 valEp3 driftStatus
1e-61.11341.11391.11480.97Stable
2e-61.11411.11661.11991.93Stable
5e-61.11861.13401.16464.85Overfitting

The masked CE fix expanded the stable LR range. Before the fix, even lr=1e-5 diverged catastrophically. After the fix, lr=5e-6 shows only mild overfitting — a 5× improvement in LR headroom.

Caveats

Files Changed

← All transmissions