Skip to content

test(gap9): add the LoRA and QLoRA CCT training models to CI - #54

Open
runwangdl wants to merge 15 commits into
develfrom
test/qlora-lora-models
Open

test(gap9): add the LoRA and QLoRA CCT training models to CI#54
runwangdl wants to merge 15 commits into
develfrom
test/qlora-lora-models

Conversation

@runwangdl

@runwangdl runwangdl commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Adds the LoRA and QLoRA CCT training models to CI, and carries the kernel and
frontend work they depend on. This supersedes #50, #51 and #53, which are closed in
favour of this one.

Models

CCT_LoRA_R1 and CCT_QLORA_FT join the GAP9 tiled training configurations at
--l1 122000.

Gemm with a one-dimensional bias (was #50)

GEMMLayer.computeShapes widened the C operand to [M, N] unconditionally, because
the kernel walked the bias as a full matrix. A Linear's bias is O values shared by
every output row, so the widened operand stored the same vector M times: on CCT-2
at 64 tokens, 32 KB to carry 512 B. The kernel now takes a bias row stride, O
reproducing the old walk exactly and 0 re-reading one vector per row, so a single
kernel serves both layouts and the previous behaviour is bit-identical at stride O.

The tiling side matters as much as the kernel. serializeTilingSolution built the C
cube from the output cube, so a [O] bias was still transferred as if it were
[M, O]. A broadcast bias now gets a cube of its own rank. Measured on the generated
code for cct_train at --l1 128000 --defaultMemLevel L3, node_84:

C_transfer_size total
before {28672, 4096} 32768 B
after {448, 64} 512 B

512 B is the whole bias, O = 128 floats across the two O tiles of 112 and 16. The
oversized cube is invisible whenever the operand is already resident, which is why
the L2 job passed while the L3 and promote jobs each failed with 4 errors out of 4.

Broadcast Add (was #51)

The same treatment for Add, for the graphs whose Linear layers export as
MatMul + Add rather than Gemm.

Fused dequantisation (was #53) — present but NOT yet effective

The int8 kernels, bindings and the topology pass are here, but the fold does not
currently take effect and no claim is made for it. The pass lost its consumer-rewiring
loop, so it never pointed a MatMul/Gemm/Conv at the int8 tensor and never set
dequant_scale. Restoring the loop then trips hoistConstant, which asserts a
constant feeds at most one node: a folded weight is read by both the forward MatMul
and its backward Gemm, and this pass runs after the frontend duplication. Giving each
consumer its own int8 copy clears that one and exposes a second multi-consumer
constant from lowering.

An earlier revision of this description reported the fused kernels being called on
CCT-LoRA. That was not the fold working. It was the binding-order defect below
retyping an fp32 weight to int8, which is the same defect that failed the FP32 Conv
kernel test 512 of 512.

The 51.55M cycle figure measured earlier came from a tree that had the consumer loop
intact, so it is not reproduced by what is on this branch. Getting the fold working
needs the constant-sharing question settled — a ConstantBuffer is global and
read-only, so sharing across consumers is sound and is what keeps the memory win, but
that means relaxing the assertion rather than copying around it. That work belongs in
its own PR.

Binding order: fp32 before folded-int8

A binding is selected before a weight constant's type is fixed, and
typeInferGlobalCtxt types that constant from the SELECTED binding's input_types.
Listing the int8 variant first therefore claims ordinary fp32 weights and retypes
them to int8, silently quantising them:

Kernels/FP32/Conv/Regular_2D_NoBias   512 errors out of 512
Kernels/FP32/GEMM/Regular             implicit declaration of PULP_Gemm_fp32_i8_fp32_fp32
Models/CCT/FP32/CCT_2_32_32_128       10 errors out of 10

fp32 is matched first across GAP9FloatGEMMBindings, GAP9FloatConv2DBindings,
GAP9MatMulBindings, PULPFloatConv2DBindings and PULPMatMulBindings.

Constant duplication after folding

cct_lorar1_train failed to generate at all, on the model-adding commit by itself:

AssertionError: Constant node_0_classifier_blocks_0_self_attn_proj_Transpose__0_tensor
has more than one output

Folding collapses Constant -> Transpose -> Variable into one Constant, and a
training graph has the forward MatMul and the backward Gemm both reading it.
_duplicateConstants runs before _foldConstants, so nothing splits what the fold
merged. It now runs again afterwards; it only touches tensors with more than one
consumer, so it is a no-op when the fold introduced none.

🤖 Generated with Claude Code

runwangdl and others added 14 commits July 28, 2026 01:21
…ing it

GEMMLayer.computeShapes widens the C operand to [M, N] unconditionally, because
PULP_Gemm_fp32_fp32_fp32_fp32 walked the bias as a full matrix (pDstC[i * O + j]).
A Linear's bias is O values shared by every output row, so the widened operand
stores the same vector M times: on CCT-2 at 64 tokens a 128-wide bias occupies
32 KB to carry 512 B.

Give the kernel a bias row stride. O reproduces the previous walk exactly, 0 makes
every row re-read the same vector. PULPFloatGEMMTemplate picks the stride from the
C operand's own rank, PULPGEMMLayer skips the widening when the bias arrives
one-dimensional, and the tile constraint then ties only the trailing dimension to
the output instead of requiring an M extent the bias does not have.

Registered on both PULPOpen and GAP9. GAP9 keeps its own operator mapping in
Deeploy/Targets/GAP9/Platform.py, so registering a layer in the PULPOpen mapping
alone has no effect on a GAP9 deployment -- worth knowing before measuring, since
an unregistered change looks exactly like an ineffective one.

Targets/Generic/Layers.py is untouched, so other targets keep the [M, N] bias
their kernels expect.

Verified on GAP9 gvsoc (CCT linear probing, --l1 122000 --defaultMemLevel L3):
Errors: 0, 37.69M train cycles.

Scope of the saving: this reaches graphs whose Linear layers are exported as Gemm.
The single-block CCT variants measured alongside this are exported as MatMul + Add
and contain no Gemm nodes at all, so their 252 KB of widened bias is untouched
here; that path is addressed separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AddLayer.computeShapes rewrites the lower-rank operand to the higher-rank one's
shape. A Linear exported as MatMul + Add hands the Add a bias of shape [O] against
an activation of [1, M, O], so the bias is stored as M identical rows: on CCT-2 at
64 tokens a 128-wide bias occupies 32 KB to carry 512 B, and the eight live at the
peak account for 252 KB.

Keep the bias at [O] and teach the three places that assumed equal shapes:

  PULPFloatAddTemplate      walks the second operand with a wrapped row index. The
                            index is advanced and reset rather than taken modulo,
                            because there is no hardware divide on this core.
  PULPBroadcastAddTileConstraint
                            constrains only the trailing axis, and gives the
                            broadcast operand the trailing slice of the output cube
                            instead of the whole cube.
  PULPAddLayer              skips the rewrite when the smaller operand has a single
                            non-unit axis.

Each falls back to the inherited elementwise behaviour when the operands do have
equal shapes, so an ordinary Add is untouched. Registered on both the PULPOpen and
GAP9 mappings; GAP9 keeps its own, and a layer registered only in PULPOpen's has no
effect on a GAP9 deployment.

Measured on GAP9, --l1 122000 --defaultMemLevel L3, L3 peak, address-deduplicated:

  CCT linear probing        1954.9 -> 1702.6 KB   (-12.9%)
  CCT LoRA on one block     1965.5 -> 1713.0 KB   (-12.8%)
  CCT full FT on one block  2376.8 -> 2252.0 KB   ( -5.3%)

The bias's own L3 allocation drops from cl_ram_malloc(32768) to cl_ram_malloc(512),
and the activation side is unchanged at 512.0 KB, which is the expected shape of the
saving: a bias is persistent, not an activation.

Verified on gvsoc: linear probing Errors: 0 (37.68M train cycles), one-block full
fine-tuning Errors: 0 (54.94M). The LoRA variant does not build, with or without
this change: hoistConstant asserts one consumer per Constant, and a LoRA graph's
frozen transposed weight feeds both the forward Gemm and its gradient. A control
run on this branch without this commit fails with the identical assertion, so that
limitation is pre-existing; its memory figure above therefore comes from the
allocation plan, not from a run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A weight-only-quantised model stores the frozen weight as int8 and needs it as
float for the fp32 matmul. Emitting that conversion as its own Dequant node
materialises the whole dequantised matrix: on CCT-QLoRA, 1088 KB of fp32 tensors
that exist only to be read by the next node and then have to stay live from the
forward pass to the backward one, or be recomputed. QLoRA does not work that way --
it dequantises inside the matmul and discards the value.

PULP_MatMul_fp32_i8_fp32_unroll1x7 takes the int8 weight with its per-tensor scale
and zero point. The affine dequantisation factors out of the inner loop entirely,

  sum_k a_k * (b_k - zp) * scale  ==  scale * (sum_k a_k * b_k - zp * sum_k a_k)

so it costs one multiply per output element rather than one per multiply-accumulate,
and with zeroPoint 0 the correction term disappears and the loop is the fp32 loop
with an int8 load.

MatMulParser passes dequant_scale / dequant_zero_point through with defaults that
leave every graph without a folded Dequant on exactly the fp32 path it used before.
The binding is listed ahead of the plain fp32 one because its signature is the more
specific.

Verified on GAP9 gvsoc, CCT-QLoRA with 12 of its 14 Dequant nodes folded into their
matmuls: Errors: 0, 390.8M train cycles against 407.9M unfolded (-4.2%).

Two things this does NOT do, measured rather than assumed:

The cycle saving is small because the cost is not where folding reaches. Profiling
attributes 239.95M cycles to Dequant, but the folded chains are the long tail: the
remaining unfolded Dequant feeds the tokenizer's Conv with a 288 KB weight, and
Conv's pre-kernel time is 143.90M against 0.87M for the same model unquantised.
Folding the Conv weight chain needs its own kernel variant and is the larger win.

The peak gets WORSE on its own: 1800 -> 2628 KB. Removing the Dequant gives the
int8 constant two direct consumers, the forward matmul and its gradient, and
_duplicateConstants then stores one copy per consumer -- _DUPLICATE_FOR_ goes from
140 occurrences to 446 and L3 constant allocation from 296.8 KB to 549.0 KB. This
wants byte-identical L3 constants to share storage, which is what #48 does; the two
belong together and this should not be merged expecting a memory win without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… pass

The kernel added in the previous commit needs the graph rewritten to use it. Do
that in PULPOptimizer, next to QuantPatternPass and DequantPatternPass, rather than
asking anyone to pre-process their ONNX: any weight-only-quantised graph now folds
on deployment.

FoldDequantIntoMatMulPass walks the graph directly instead of matching a pattern.
That is deliberate. The Dequant worth folding is read by TWO nodes -- the forward
matmul and its gradient -- and that fan-out is exactly what
ReplaceSequentialPatternPass skips; measured, the sequential matcher reaches 3 of
14 Dequant nodes and BranchingMatcher reaches none. A Dequant with a single
consumer is the cheap tail. Each consumer dequantises independently after folding,
which repeats the conversion on purpose: it is register-level work inside the
kernel, and cheaper than keeping a materialised fp32 copy alive across the step.

Verified on GAP9 gvsoc, CCT-QLoRA: 14 Dequant nodes down to 2, Errors: 0, 394.9M
train cycles against 407.9M unfolded.

The two that remain feed a Conv and an Add and need their own kernel variants. They
are also where the cost is: profiling attributes 239.95M cycles to Dequant, and the
Conv one is the tokenizer's 288 KB weight whose pre-kernel time is 143.90M against
0.87M for the same model unquantised. Folding the twelve matmul chains is worth
13M; the Conv chain alone is worth an order of magnitude more. That variant is the
next step, not part of this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three configurations that were only ever run by hand, now registered so CI covers
them.

Models/Training/CCT_LoRA_R1 -- rank-4 LoRA on CCT-2 at the official spec
(mlp_ratio=1, adapters on attention and FFN). Distinct from the existing CCT_LoRA
entry, which predates the mlp_ratio fix. Measured 2292 KB peak, 54.5M cycles: it is
*faster* than full fine-tuning of the same network (66M), because freezing the base
weights removes their weight gradients.

Models/Training/CCT_QLORA_FT -- the same model with its frozen backbone quantised
to int8, per-tensor symmetric. This is the one worth having in CI: quantisation
here *raises* the peak rather than lowering it, because storing the weight as int8
turns a chain constant folding would have collapsed into one it cannot, leaving two
live fp32 tensors where there was one folded constant. The model does not fit on
chip at all unless the Dequant is folded into the kernels, so it is a regression
test for that path.

ResNet8 added to L2_SINGLEBUFFER_TRAINING_MODELS -- it fits on chip at 1316 KB with
the memory-minimising schedule, next to the ~171 KB static section in 1.5 MB of L2.

Each model keeps only the three files the other test models keep (inputs.npz,
network.onnx, outputs.npz); the exporter's intermediate artifacts are dropped,
which takes CCT_QLORA_FT from 5.5 MB to 1.8 MB and CCT_LoRA_R1 from 6.3 to 2.6 MB.

Not included: the rematerialised (gradient-checkpointed) variants of CCT and
CCT-LoRA. Those are driven by an injected node order supplied as an external
sequence file, which is experiment scaffolding rather than a Deeploy feature, and
the sequence is keyed on node names so it silently degrades to zero clones when a
pass rewrites the graph. They need rematerialisation to become a first-class option
before they can be a CI test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The one-dimensional bias reached the kernel correctly but was tiled as if it were
still [M, O]. serializeTilingSolution built the C cube straight from the output
cube:

    CCube = HyperRectangle(tuple(cube.offset), tuple(cube.dims))

so the DMA was told to move M * O elements out of a buffer holding O. On CCT at 64
tokens that is 32768 B read from a 512 B buffer, 64x past the end, and the tile the
kernel then read was garbage.

It only showed up on the L3 and promote configurations because those are the ones
that actually transfer the operand; with everything resident in L2 there is no copy
and the oversized cube costs nothing. That is why siracusa-training-tiled-l2 passed
while the four L3 and promote jobs each failed with 4 errors out of 4, and why the
GAP9 jobs, whose smaller L1 budget yields a different tiling solution, did not catch
it either.

A broadcast bias now gets a cube of its own rank: the trailing O extent tracks the
output tile, the leading dimensions stay 1. Measured on the generated code for
Models/Training/CCT/cct_train at --l1 128000 --defaultMemLevel L3, node_84:

    before   C_transfer_size = {28672, 4096}   32768 B
    after    C_transfer_size = {448, 64}         512 B

512 B is the whole bias, O = 128 floats, split across the two O tiles of 112 and 16.
A widened [M, O] bias is untouched and still tiles like the output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts:
#	Deeploy/Targets/PULPOpen/Platform.py
…add51

# Conflicts:
#	Deeploy/Targets/GAP9/Platform.py
#	Deeploy/Targets/PULPOpen/Platform.py
…rd Gemm

The topology pass and the PULPOpen MatMul kernel were pushed without the pieces
that make them take effect, so the fold rewrote the graph and nothing consumed it.

GAP9 keeps its own binding lists. Registering an int8 binding in PULPMatMulBindings
does nothing for a GAP9 deployment, so GAP9MatMulBindings, GAP9FloatConv2DBindings
and GAP9FloatGEMMBindings each get one here.

The backward Gemm needs its own int8 binding, not just the forward MatMul. Forward
and backward share the weight constant, and typeInferGlobalCtxt types a constant
from the selected binding's input_types: if the backward Gemm matches an fp32
binding, the shared constant is re-typed to float and its allocation quadruples.
That is why an earlier attempt reported Errors: 0 with zero call sites into the
fused kernels while looking faster, since a wrong-type read moves a quarter of the
bytes.

Also adds the fused Conv and Gemm kernels the bindings point at, with im2col_sum
hoisted above the channel loop, and the templates that pass scale and zero_point.

Measured on GAP9 gvsoc, CCT-QLoRA at --l1 122000 --defaultMemLevel L3:
Errors: 0, 51,545,289 train cycles, with the fused kernels actually called
(13 MatMul, 2 Conv, 12 Gemm call sites), against 407.9M unfused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…x/combo54

# Conflicts:
#	Deeploy/Targets/PULPOpen/Platform.py
#	TargetLibraries/GAP9/inc/DeeployMchan.h
Folding can hand one constant to several consumers. A weight reaching the kernel as
Constant -> Transpose -> Variable collapses to a single Constant, and in a training
graph the forward MatMul and the backward Gemm both read it. _duplicateConstants
runs before _foldConstants, so nothing splits what the fold just merged, and
hoistConstant then trips its own assertion:

    AssertionError: Constant node_0_classifier_blocks_0_self_attn_proj_Transpose__0_tensor
    has more than one output

Run the duplication again after the fold. It only acts on tensors with more than one
consumer, so it costs nothing when the fold introduced none.

This is what made cct_lorar1_train fail to generate at all; the failure is present on
the model-adding commit alone and is not introduced by the kernel work merged
alongside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The int8 bindings added for folded dequantisation were listed first, on the reasoning
that the more specific signature should be tried first. That reasoning is wrong here.
A binding is selected before a weight constant's type is fixed, and
typeInferGlobalCtxt then types that constant from the SELECTED binding's input_types.
Listing int8 first means an ordinary fp32 weight is claimed by it and retyped to int8,
which silently quantises the weight.

It shows up wherever an fp32 graph has a Gemm, Conv or MatMul against a constant:

    Kernels/FP32/Conv/Regular_2D_NoBias   512 errors out of 512
    Kernels/FP32/GEMM/Regular             implicit declaration of
                                          PULP_Gemm_fp32_i8_fp32_fp32
    Models/CCT/FP32/CCT_2_32_32_128       10 errors out of 10

Ordering fp32 first fixes it without weakening the int8 path: a genuinely int8
constant already carries that type, so the fp32 checker rejects it and the int8
binding still wins. Applied to GAP9FloatGEMMBindings, GAP9FloatConv2DBindings,
GAP9MatMulBindings, PULPFloatConv2DBindings and PULPMatMulBindings.

Isolated with clean builds: the pre-existing test passes on this branch without the
kernel work, still fails with the post-fold constant duplication removed, and passes
once the binding order is corrected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant