Merging the adapter and storing it in int8

Merging folds LoRA into the base weights so inference runs one matmul per projection. The first merge check failed, because its tolerance was smaller than one float16 rounding step. With a correct gate, the merged int8 model halves the stored projections, keeps pass^1, and runs faster than the unmerged adapter.

The fine-tuned adapters work, but they cost something at inference. Every adapted projection runs three matmuls instead of one: the base weight, then A, then B. This post folds the adapter into the base weights, checks the fold, and stores the result with Day 5's int8 path.

The merge

One matmul instead of three

The adapter's contribution is linear in xx, so it folds into the weight once:

W0x+αrBAx=(W0+αrBA)x=WxW_0 x + \frac{\alpha}{r} B A x = \Big(W_0 + \frac{\alpha}{r} B A\Big) x = W x

After the merge, the model has the base model's shapes and speed. octlm computes αrBA\frac{\alpha}{r} B A in float32, adds it to W0W_0 in float32, and casts the sum back to float16, as PEFT does.

Merge a toy layer
Rank r
B
Merged W = W₀ + (α/r)·B·A, 4×6
-2.090  3.308  0.900  3.416  -3.605  2.846
-1.112  -3.575  -3.450  3.871  3.782  0.250
-0.385  0.383  0.840  2.219  2.238  -0.655
-0.207  2.817  -1.100  -0.148  -3.264  1.993
Adapted vs base output0.0e+0exactly zero: B·A is zero
Merged vs unmerged, float640.0e+0the merge is exact up to rounding
Merged weights stored as float163.4e-3the rounding cost of storing W

A toy layer with 6 inputs and 4 outputs, α = 4. The adapter adds x·Aᵀ·Bᵀ·α/r to the base output. Merging folds B·A·α/r into W once, so inference runs one matmul instead of three. The merge itself is exact. The error comes from storing the merged W in float16, and it grows with the size of the weights and the length of the sums.

A 4-by-6 layer with a rank-r adapter. At step 0 the adapter changes nothing. After training, the merged weight reproduces the adapted output exactly in float64, and storing it in float16 costs a rounding error.
octlm/day8.pyline 84
def merge_lora(model: Decoder) -> None:
    for parent, name in projections(model):
        setattr(parent, name, getattr(parent, name).merged())
A failed check

The first gate was tighter than float16

The plan said to stop if merged and unmerged logits differed by more than 0.05, "the float16 rounding scale". The first T4 run measured 0.157 on a 516-token prompt and stopped before the int8 eval.

That looked like a broken merge. A float32 check on the laptop said otherwise:

ComparisonMax logit difference
Float32 merged against float32 unmerged0.000115
Merged weights rounded to float16, float32 compute0.042
T4, float16 weights and activations0.157

In float32 the merge is exact to about 10−410^{-4}. Rounding only the merged weights to float16 already costs 0.042, and float16 activations across 28 blocks add the rest. The 0.05 was a guess. It was never measured, and it broke the project's own rule that a tolerance comes from a measured noise floor.

How big is one float16 step?
1 to 20.0010
2 to 40.0020
4 to 80.0039
8 to 160.0078
16 to 320.0156
32 to 640.0313
64 to 1280.0625
laptop f16 W0.0420
T4 run 10.1572
T4 rerun0.1758
float16 step at this size0.0313gap between neighbouring values near 38
Tolerance set before the run0.05larger than one rounding step
Example38.01156 → 38.00000a float32 value stored as float16

Each row is one power-of-two range of logit sizes and its float16 step, the distance between adjacent float16 values. Red rows have steps above the 0.05 tolerance. Blue rows are the measured merge differences on the T4. Qwen's largest logit on the gate prompt is about 38, in the 32 to 64 range, where one step is already 0.031.

float16 has 10 mantissa bits, so the gap between neighbouring values doubles with every power of two. Move the slider to see the step at any logit size.

The largest logit on the prompt is about 38. Near 38, adjacent float16 values are 25−10=0.0312^{5-10} = 0.031 apart. Any tolerance near 0.05 sits within two rounding steps of the largest logits, so ordinary rounding trips it.

The new gate asks a question rounding can't fake. Do the merged and unmerged models pick the same top-1 token at every position of the prompt? It still logs the max difference. The float32 table is the proof that the math is right.

The rerun

Same top token at all 516 positions

Kaggle wouldn't take the errored run's adapter as an input, and uploading the 40 MB file from the laptop stalled. So the rerun trained seed 0 again on the T4, then merged, quantized and ran the full eval.

Gate measureValue
Top-1 agreement, merged vs unmerged1.000
Max logit difference0.176
Block weights, float16880.9 MB
Block weights, int8441.9 MB
Ratio0.50
A confound

The rerun trained a different adapter

The rerun's adapter hash is {hash(env)}. The first run's was {hash(eval0[0])}. Same code, same data, same seed, different bits. The two loss curves agree within 0.001 for the first ten steps, then drift apart. Step 42 is 0.077 in the first run and 0.045 in the rerun.

Two seed-0 runs
Loss axis
first runrerun
9.8e−43.9e−31.6e−26.3e−22.5e−11.0e+0020406080optimizer steploss on target tokensfirst runrerun
Hover the chart to read values.

The curves agree within a few percent for the first 20 steps, separate by the end of the first epoch, and differ by a factor of two or more on many steps of the third, where the losses are tiny.

Seed 0 trained twice on a T4 with the same code and data. Nondeterministic GPU kernels and a few tokens of different tool output in the traces push the runs apart.

Two things differ between the runs. Some CUDA kernels PyTorch uses in training are nondeterministic unless you ask for deterministic ones, and the training traces contain a random sandbox path and unittest timings that change from run to run. So the int8 eval below runs a different adapter from the float16 eval it's compared with.

Result

pass^1 holds at 0.375

MeasureUnmerged float16, b0327504Merged int8, 3ee2a82b
pass^1 mean0.3750.375
pass^1 per sampling seed0.350, 0.375, 0.4000.375, 0.400, 0.350
pass^30.3500.325
Valid-call rate0.9760.954
Success, answer0.3330.381
Success, file0.8150.852
Success, hidden0.2500.250
Success, tests0.1110.000
Runs stopped by the turn limit2655
Mean turns4.625.42
Generated tokens per second13.917.1

The mean is identical. Run by run, 35 of 120 runs pass in both evals, 10 pass only in float16, and 10 pass only in int8. The sets of solved runs overlap a lot but aren't the same, which is what two samplers over two similar models should produce.

Run by run
Model
answerfilehiddentestsseed 0seed 1seed 2
passedcalled a tool, failedno tool calldifferent result in the other model
Runs passed45 of 120pass^1 0.375
Runs with a tool call120 of 120Unmerged float16: 120
Picked runfailed8 turns, 8 of 8 calls valid

port (answer): Which port does the server use according to the config? Reply with the answer.
Final answer: none, the run hit the turns limit

Switch between the unmerged float16 adapter and the merged int8 model. Dashed cells changed result between the two.

The turn-limit stops doubled, from 26 to 55. Most of the rise is answer tasks, 1 in float16 and 22 in int8. The int8 model keeps reading files after it has the value, yet answers correctly as often. This eval can't say whether the new adapter or the int8 weights cause the looping, because both changed at once.

Speed went up, from 13.9 to 17.1 tokens per second. The stock model ran at 18.3. Merging removes 14 adapter matmuls per block, and int8 dequantization costs less than they did. Day 5 found int8 slower than float16 on the 20M model, so the gain comes from the merge, not from int8. A float16 merged eval would measure the split. It wasn't run.

Decision: keep. The merge is exact in float32 and passes the top-1 gate. int8 halves the stored block weights, and pass^1 matches the float16 adapter. A fair comparison still needs the float16 eval of adapter {hash(env)}, one more 20-minute T4 run.

Skipped

Not built