LoRA from scratch

Fine-tuning all 600M Qwen weights needs about 9.7 GB of optimizer state before a single activation. LoRA freezes them and trains two thin matrices per projection instead. octlm's version trains 1.7 percent of the parameters and matches the base model's logits exactly at step 0.

Day 7 ended with a clear diagnosis. Stock Qwen3-0.6B answers without reading the repository, and when it does write a file, the JSON breaks. Day 8 fine-tunes the model on examples of doing it right. Before any of that, we need a way to train a 600M-parameter model on a free T4 with 15 GB of memory.

The memory problem

Full fine-tuning doesn't fit

Plain fine-tuning updates every weight. With mixed precision and AdamW, each parameter costs 16 bytes of state: a float32 master copy, a float32 gradient, and two float32 Adam moments. Qwen3-0.6B has 606,142,464 parameters in octlm's decoder, so that's 9.7 GB before activations. Activations for a 1,000-token example come on top. A T4 has 15 GB, and full fine-tuning would leave almost none of it for activations.

The idea

Train a small correction, not the weights

LoRA freezes the original weight matrix W0W_0 and learns a low-rank update next to it. For a projection with dind_\text{in} inputs and doutd_\text{out} outputs:

h=W0x+αrBAx,A∈Rr×din,  B∈Rdout×rh = W_0 x + \frac{\alpha}{r} B A x, \qquad A \in \mathbb{R}^{r \times d_\text{in}},\; B \in \mathbb{R}^{d_\text{out} \times r}

The rank rr is small, 16 here, against din=qwen.config.dmodeld_\text{in} = {qwen.config.d_model}. So AA and BB together hold r(din+dout)r(d_\text{in} + d_\text{out}) numbers, while W0W_0 holds dindoutd_\text{in} d_\text{out}. The bet is that the change fine-tuning needs lives in a few directions, so a rank-16 update can carry it.

Two details make it work:

One adapted projection

The input to one projection, here the query projection of one block, in float16.

Stage 1 of 5
Click through the forward pass of one LoRALinear. The base path and the adapter path see the same input and add at the end.
octlm/day8.pyline 40
class LoRALinear(nn.Module):
    def __init__(self, base: nn.Linear, rank: int, alpha: float, generator: torch.Generator):
        super().__init__()
        self.base, self.scale = base, alpha / rank
        a = torch.empty(rank, base.in_features)
        nn.init.kaiming_uniform_(a, a=math.sqrt(5), generator=generator)
        device = base.weight.device
        self.lora_a = nn.Parameter(a.to(device))
        self.lora_b = nn.Parameter(torch.zeros(base.out_features, rank, device=device))

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        update = x.to(self.lora_a.dtype) @ self.lora_a.T @ self.lora_b.T
        return self.base(x) + (update * self.scale).to(x.dtype)

    def merged(self) -> nn.Linear:
        weight = self.base.weight
        linear = nn.Linear(
            self.base.in_features,
            self.base.out_features,
            bias=False,
            device=weight.device,
            dtype=weight.dtype,
        )
        delta = self.lora_b.float() @ self.lora_a.float() * self.scale
        linear.weight.data.copy_(weight.float() + delta)
        return linear
Where to put adapters

All seven projections, not just attention

The LoRA paper adapted only the query and value projections. Later work disagreed. QLoRA found that query and value adapters alone could not match full fine-tuning, and that "LoRA on all linear transformer block layers are required". Thinking Machines' LoRA Without Regret found attention-only LoRA "significantly underperforms" MLP-only LoRA. octlm adapts all seven projections in every block: query, key, value and output in attention, and gate, up and down in the SwiGLU feed-forward.

Count it
Rank r
Adapted projections
Full fine-tune9.7 GB
LoRA1.4 GB
Trainable parameters10,092,5441.67% of 606,142,464
Per layer360,448r × (in + out), summed over 7 projections
Adapter file38.5 MiBfloat32 A and B

Memory counts weights and optimizer state only, no activations. A full fine-tune keeps float32 master weights, gradients and two AdamW moments: 16 bytes per parameter. LoRA keeps the frozen model in float16, 2 bytes per parameter, and pays the 16 bytes only for A and B. Rank 16 on all seven projections is the setting octlm trained.

Pick a rank and the projections to adapt. The counts use Qwen3-0.6B's real shapes, and the TypeScript count matches the trainable-parameter count logged on the T4.

The feed-forward projections dominate the count. Each is 1,024 by 3,072, so they hold most of the weights the adapters can reach. Dropping them cuts the adapter by more than half and skips the layers QLoRA and Thinking Machines found matter most.

Result

Exactly zero at step 0

The plan set one stop condition before the run. Because BB starts at zero, any nonzero logit difference at step 0 means the code is wrong. The training stage runs the base model and the freshly adapted model on the longest training example, 1,157 tokens, and compares their logits.

MeasureSeed 0Seed 1
Step-0 max logit difference00
Trainable parameters10,092,54410,092,544
Share of all parameters1.67%1.67%
Peak CUDA memory in training7.9 GB7.9 GB
Training time, 84 steps268 s272 s

The difference is exactly 0.0 in all 3 training runs, including the later rerun of seed 0. It's not merely small. The adapter output is cast to float16 before the add, and 0×0 \times anything is an exact zero, so the sum is bit-identical. Peak memory stayed near half the T4.

Decision: keep. The adapters fit with room to spare, and step 0 is provably the base model.

Skipped

Not built