test(gap9): add the LoRA and QLoRA CCT training models to CI - #54
Open
runwangdl wants to merge 15 commits into
Open
test(gap9): add the LoRA and QLoRA CCT training models to CI#54runwangdl wants to merge 15 commits into
runwangdl wants to merge 15 commits into
Conversation
…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>
This was referenced Jul 28, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_R1andCCT_QLORA_FTjoin the GAP9 tiled training configurations at--l1 122000.Gemm with a one-dimensional bias (was #50)
GEMMLayer.computeShapeswidened the C operand to[M, N]unconditionally, becausethe kernel walked the bias as a full matrix. A Linear's bias is
Ovalues shared byevery output row, so the widened operand stored the same vector
Mtimes: on CCT-2at 64 tokens, 32 KB to carry 512 B. The kernel now takes a bias row stride,
Oreproducing the old walk exactly and
0re-reading one vector per row, so a singlekernel serves both layouts and the previous behaviour is bit-identical at stride
O.The tiling side matters as much as the kernel.
serializeTilingSolutionbuilt the Ccube 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 generatedcode for
cct_trainat--l1 128000 --defaultMemLevel L3, node_84:C_transfer_size{28672, 4096}{448, 64}512 B is the whole bias,
O = 128floats across the two O tiles of 112 and 16. Theoversized 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 asMatMul + Addrather thanGemm.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 tripshoistConstant, which asserts aconstant 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:
fp32 is matched first across GAP9FloatGEMMBindings, GAP9FloatConv2DBindings,
GAP9MatMulBindings, PULPFloatConv2DBindings and PULPMatMulBindings.
Constant duplication after folding
cct_lorar1_trainfailed to generate at all, on the model-adding commit by itself:Folding collapses
Constant -> Transpose -> Variableinto one Constant, and atraining graph has the forward MatMul and the backward Gemm both reading it.
_duplicateConstantsruns before_foldConstants, so nothing splits what the foldmerged. 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