feat(training): replay gradient-checkpointing schedules, and run the verified CCT one in CI - #55
Merged
Conversation
…lass scheduler
Gradient checkpointing drops an activation after its forward use and regenerates it
before the backward pass reads it. The solve that decides what to drop runs on the
training graph; what was missing was a supported way to replay that decision on the
graph Deeploy actually deploys, which is not the graph the solve saw because lowering
has run in between. Until now that replay lived outside the tree, as a sitecustomize
patch over trainingUtils._mockScheduler driven by environment variables.
testMVPTraining.py --recomputeSchedule <json> takes {"seq": [[node, is_recompute]]}
in run order and replays it. Each recompute becomes a short-lived clone and the
consumers scheduled after it read the clone, so the original's output dies at its
last forward use instead of staying live across the backward pass.
The parts that are not obvious, each of which was a failure before it was handled:
- Lowering can leave a stale same-named node with its outputs cleared. Scheduling
it puts an empty layer into layerBinding and fails far from the cause, so name
lookup prefers a node that still produces something.
- The scheduler is called twice, for binding and for the tiler. The second call
replays the order cached from the first; cloning again would produce a second
set of clones for the same recomputes.
- Nodes the schedule does not name still have to run, and cannot go at the end. A
constant duplicated per consumer during folding produces a tensor its consumer
reads, so appending it after that consumer leaves the input unresolved. They are
spliced in after whatever produces their inputs.
- A solve can place a recompute whose consumers linearise before the clone, or
rewire every consumer of an original to a clone. Either leaves a node nothing
reads, whose output cleanup strips. Both are pruned to a fixpoint, since
dropping one can orphan its producer.
- A clone shares the original's constants rather than copying them. A copy charges
the full constant again per clone, so recomputing a node that reads a weight
grows the persistent region instead of shrinking the peak: on the int8 QLoRA CCT,
117 clones took persistent from 662 KB to 938 KB.
A schedule is keyed on node names and lowering renames nodes, so replaying a solve
against a graph it did not come from would drop every recompute and quietly run the
default order under a name claiming to be checkpointed. That is invisible in a
pass/fail CI result, so a replay matching under 90% of its entries is refused rather
than reported as a green run of the wrong thing.
Verified on GAP9, CCT at --l1 122000 --defaultMemLevel L3, replaying the 250-entry
Checkmate schedule: all 6 recomputes appear in the generated code as __ck clones.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the one CCT recompute schedule that has been checked end to end, as its own job rather than folded into the singlebuffer one, so it does not stretch that job's runtime and a failure points at the schedule. model schedule Errors cycles/step CCT (exact ILP) recompute_checkmate.json 0 68.63M vs 64.24M (+6.8%) The extra cycles are the point: checkpointing buys peak memory with compute, so this runs alongside the ordinary singlebuffer entry rather than replacing it. Only schedules verified on gvsoc are listed. The four operating points from the memory-budget sweep are not here: they have LP-predicted activation and latency but no gvsoc run, and their sequences were never emitted, so nothing exists to replay. Adding them would put unverified configurations in as regression baselines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two CCT LoRA models were in CI. Models/Training/CCT_LoRA/cct_lora_train is the small one: 184 KB of ONNX, 309 nodes, and an L1 budget of 40000 rather than the 122000 the other CCT variants get. CCT_LoRA_R1 is the model the LoRA numbers are actually about, rank-4 at the official spec with adapters on attention and FFN, 1.3 MB of ONNX and 387 nodes, at 2292 KB peak and 54.5M cycles. Keeping both spends a GAP9 gvsoc slot on the smaller model without covering anything the full one does not. The Siracusa side had only a TRAINING_MODEL_OVERRIDES entry for it and no model-list entry, so that override was already dead config; it goes too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
runwangdl
added a commit
that referenced
this pull request
Jul 29, 2026
* perf(PULPOpen): let Gemm take a one-dimensional bias instead of widening 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>
* perf(PULPOpen): add a vector bias without materialising the broadcast
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>
* feat(PULPOpen): matmul that dequantises an int8 weight in-kernel
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>
* feat(PULPOpen): fold a weight's Dequant into the matmul as a topology 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>
* test(gap9): add the LoRA and QLoRA CCT training models to CI
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>
* fix(PULPOpen): transfer only the bias vector for a broadcast Gemm bias
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>
* feat(GAP9): register the fused dequant kernels on GAP9 and the backward 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>
* fix(frontend): duplicate constants again after folding
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>
* fix(bindings): match the fp32 kernel before the folded-int8 one
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>
* feat(training): replay gradient-checkpointing schedules, and run the verified CCT one in CI (#55)
* feat(training): replay a gradient-checkpointing schedule as a first-class scheduler
Gradient checkpointing drops an activation after its forward use and regenerates it
before the backward pass reads it. The solve that decides what to drop runs on the
training graph; what was missing was a supported way to replay that decision on the
graph Deeploy actually deploys, which is not the graph the solve saw because lowering
has run in between. Until now that replay lived outside the tree, as a sitecustomize
patch over trainingUtils._mockScheduler driven by environment variables.
testMVPTraining.py --recomputeSchedule <json> takes {"seq": [[node, is_recompute]]}
in run order and replays it. Each recompute becomes a short-lived clone and the
consumers scheduled after it read the clone, so the original's output dies at its
last forward use instead of staying live across the backward pass.
The parts that are not obvious, each of which was a failure before it was handled:
- Lowering can leave a stale same-named node with its outputs cleared. Scheduling
it puts an empty layer into layerBinding and fails far from the cause, so name
lookup prefers a node that still produces something.
- The scheduler is called twice, for binding and for the tiler. The second call
replays the order cached from the first; cloning again would produce a second
set of clones for the same recomputes.
- Nodes the schedule does not name still have to run, and cannot go at the end. A
constant duplicated per consumer during folding produces a tensor its consumer
reads, so appending it after that consumer leaves the input unresolved. They are
spliced in after whatever produces their inputs.
- A solve can place a recompute whose consumers linearise before the clone, or
rewire every consumer of an original to a clone. Either leaves a node nothing
reads, whose output cleanup strips. Both are pruned to a fixpoint, since
dropping one can orphan its producer.
- A clone shares the original's constants rather than copying them. A copy charges
the full constant again per clone, so recomputing a node that reads a weight
grows the persistent region instead of shrinking the peak: on the int8 QLoRA CCT,
117 clones took persistent from 662 KB to 938 KB.
A schedule is keyed on node names and lowering renames nodes, so replaying a solve
against a graph it did not come from would drop every recompute and quietly run the
default order under a name claiming to be checkpointed. That is invisible in a
pass/fail CI result, so a replay matching under 90% of its entries is refused rather
than reported as a green run of the wrong thing.
Verified on GAP9, CCT at --l1 122000 --defaultMemLevel L3, replaying the 250-entry
Checkmate schedule: all 6 recomputes appear in the generated code as __ck clones.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(gap9): run the verified CCT gradient-checkpointing schedule in CI
Adds the one CCT recompute schedule that has been checked end to end, as its own
job rather than folded into the singlebuffer one, so it does not stretch that job's
runtime and a failure points at the schedule.
model schedule Errors cycles/step
CCT (exact ILP) recompute_checkmate.json 0 68.63M vs 64.24M (+6.8%)
The extra cycles are the point: checkpointing buys peak memory with compute, so this
runs alongside the ordinary singlebuffer entry rather than replacing it.
Only schedules verified on gvsoc are listed. The four operating points from the
memory-budget sweep are not here: they have LP-predicted activation and latency but
no gvsoc run, and their sequences were never emitted, so nothing exists to replay.
Adding them would put unverified configurations in as regression baselines.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test: drop the small CCT-LoRA, keep the full-spec one
Two CCT LoRA models were in CI. Models/Training/CCT_LoRA/cct_lora_train is the small
one: 184 KB of ONNX, 309 nodes, and an L1 budget of 40000 rather than the 122000 the
other CCT variants get. CCT_LoRA_R1 is the model the LoRA numbers are actually
about, rank-4 at the official spec with adapters on attention and FFN, 1.3 MB of
ONNX and 387 nodes, at 2292 KB peak and 54.5M cycles.
Keeping both spends a GAP9 gvsoc slot on the smaller model without covering anything
the full one does not. The Siracusa side had only a TRAINING_MODEL_OVERRIDES entry
for it and no model-list entry, so that override was already dead config; it goes
too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(PULPOpen): select the folded-int8 kernel by the fold's own marker
CCT-QLoRA now trains on-chip: Errors: 0, 43.57M cycles, L2 516 KB of 1536.
The int8 kernel variants added for folded dequantisation could not be selected
correctly by operand type. A binding is chosen before a weight constant's type is
fixed, and typeInferGlobalCtxt then types that constant from the SELECTED binding's
input_types, so whichever binding is tried first claims the weight and decides what
it is. Ordering them cannot resolve that:
int8 first an ordinary fp32 weight is claimed and retyped to int8, silently
quantising it. Kernels/FP32/Conv/Regular_2D_NoBias failed 512 of 512.
fp32 first a folded int8 weight is claimed and retyped upward. On CCT-QLoRA the
weights landed as float64 and persistent went 409 KB -> 1094 KB, which
is what made the model miss L2 by 491 KB.
FoldDequantIntoMatMulPass already marks every node it rewrites with dequant_scale, so
that attribute is a discriminator independent of type. PULPDequant{MatMul,GEMM,Conv}
Checker requires it, which makes the int8 bindings invisible to an fp32 graph and lets
them be listed first, where binding selection can actually reach them.
Also here, both needed to get the model on-chip:
- hoistConstant no longer requires a constant to have a single consumer. A
ConstantBuffer is global and read-only, so sharing one is sound, and duplicating
to satisfy the assertion charges the full constant again per consumer. The
assertion blocked every graph where a frozen weight is read twice, which is the
normal shape of a quantised weight feeding a forward MatMul and its backward Gemm.
- FoldDequantIntoMatMulPass lost its consumer-rewiring loop, so it marked nothing
and rewired nothing. Restored, with consumers sharing the one int8 constant.
Measured on GAP9 gvsoc, --l1 122000 --l2 1572864 --defaultMemLevel L2, no external
patches:
Errors: 0 out of 1
BENCH train_cycles=43571998 weight_sram=49153
L2_shared: 516592 B / 1536 KB = 32.84%
fused kernels called: 12 MatMul, 9 Gemm, 1 Conv; 13 weights stay int8_t
For comparison: 407.9M unfused, 66M plain CCT, 54.5M CCT-LoRA, 51.55M the earlier L3
fused measurement. weight_sram drops from 1094 KB to 48 KB, which is what freed L2.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(GAP9): declare the MatMul kernels GAP9 emits
Generated GAP9 code includes DeeployGAP9Math.h, never PULPOpen's kernel/Matmul.h,
so PULP_MatMul_fp32_fp32_fp32_unroll1x7 was called against an implicit (int)
declaration: the float arguments go under the wrong ABI. GAP9Kernels.h already
forward-declares the gradient kernels for exactly this reason, and says so; MatMul
was simply missing from that list.
This removes the warning and the ABI hazard. It is not on its own sufficient for
Kernels/FP32/Reshape/SkipConnection, which still mismatches; that is tracked
separately.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(PULPOpen): size and orient a broadcast Add by the output
Leaving a Linear's bias at its own shape instead of storing the same row per output
row needs two things the layer change did not carry, and without them the FP32
SkipConnection kernel test produced garbage on the order of 1e13.
AddParser sized the loop from the first input. With both operands the same shape
that equals the output, but a broadcast operand does not: a [2] bias bounded the
loop at 2 while the output held 16, so fourteen outputs were never written and read
back as whatever was in memory. Size by the output instead.
PULPFloatAddTemplate decided broadcast by looking only at data_in_2. A Linear
exported as MatMul + Add puts the bias second, but the same graph after lowering can
present it first, and the template then took the elementwise branch and indexed a
two-element bias by the output index. Either operand is now recognised, and the two
are swapped so the template always adds data_in_2 into data_in_1.
Neither showed up while this work was a separate PR: the models exercised there put
the bias second in every Add.
GAP9 kernels: 31 passed. Kernels/FP32/Reshape/SkipConnection goes from 16 errors of
16 to passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(test): commit the recompute schedule the CI job names
.gitignore excludes DeeployTest/Tests/**/*.json to keep generated inputs and outputs
out of the tree, and it silently swallowed the schedule the recompute job reads. The
file existed locally, so the test passed here and failed in CI with
FileNotFoundError: Tests/Models/Training/CCT/cct_train/recompute_checkmate.json
A schedule is an input, not an artefact, so recompute_*.json is exempted rather than
force-added: the next one added will be committed by default instead of reproducing
this.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(pre-commit): keep clang-format off JSON
The hook ran with no type filter, so it picked up the recompute schedule committed
alongside it and reformatted the file into something json.load rejects. Restricting
it to C and C++ is what it was there for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(gap9): run CCT-QLoRA on-chip in CI
The L2 result was only ever produced by hand. CCT_QLORA_FT was registered for the L3
job alone, so CI proved the off-chip path and said nothing about the on-chip one.
Three settings the manual script carried but the model config never recorded, each of
which the CI path fails without:
l2 1572864 GAP9's real 1.5 MB. The runner default of 1024000 leaves the arena
715 KB against the 923 KB it needs.
cc_stack 4096 With the SDK defaults the cluster stacks overflow L1 and gvsoc exits
slave_stack 512 before printing anything.
l1 116000 CI trains 4 mini-batches, not 1. Their accumulator buffers leave the
L1 allocator 118 KB, so the 122000 arena the L3 entry uses does not
fit and allocator 2 fails.
The L2 job's capacity was hard-coded, so it now reads a per-model override.
Verified through the pytest path, not a script:
Errors: 0 out of 4
L2_shared: 552752 B / 1536 KB = 35.14%
train_cycles = 181,612,218 over 4 steps = 45.40M per step
That per-step figure is not comparable to the 43.57M reported earlier: this trains 4
mini-batches and that one trained 1, which also accounts for the 516 KB -> 553 KB in
L2 use.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(gap9): run the on-chip training models in CI
L2_SINGLEBUFFER_TRAINING_MODELS had entries but no job collecting them: every GAP9
training job filtered on l3 or promote, so nothing configured for on-chip training
had ever run. gap9-training-tiled-l2-singlebuffer now does.
CCT-QLoRA joins it. Three settings the manual runs carried and the model config did
not, each of which the CI path fails without:
l2 1572864 GAP9's real 1.5 MB. The runner default of 1024000 leaves the arena
715 KB against the 923 KB it needs.
cc_stack 4096 Otherwise the SDK's cluster stacks overflow L1 and gvsoc exits
slave_stack 512 before printing anything.
l1 116000 CI trains four mini-batches; their accumulator buffers leave the L1
allocator 118 KB, so the 122000 the L3 entry uses does not fit.
The L2 job's capacity was hard-coded, so it now takes a per-model override.
Verified through pytest, ccache disabled: 5 passed. CCT-QLoRA reports Errors: 0 out
of 4, L2_shared 552752 B of 1536 KB (35.14%), 181,612,218 cycles over four steps.
ResNet8 stays out for now. It has run on-chip before at 43.53M with Errors: 0, but
that run used one mini-batch and one data input against CI's four and two. Budget
alone does not close the gap: raising l2 and lowering l1 clears the arena and the L1
allocator in turn and it then fails in the L2 allocator, so what is missing is the
schedule. Turning num_data_inputs down to 1 would make it pass by changing what the
test covers, which is not the same thing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(gap9): run ResNet8 on-chip, channels-first
ResNet8 fits L2 with no recompute, no promotion and no double buffering. What it
needs is CHW convs: the NHWC path materialises a transposed copy of every conv
weight -- 75 of them, 2950 KB in total -- because the pass that eliminates W^T
covers Linear layers and not Conv. With CHW kernels no transpose exists and the peak
goes from 1410 KB to 1278 KB, inside L2.
Measured at one mini-batch, which is how the figure was originally recorded:
Errors: 0, 43,531,388 cycles, L2_shared 164928 B (10.49%). The l1 budget is 116000
rather than the 122000 that measurement used, because CI trains four mini-batches
and their accumulator buffers leave the L1 allocator 118 KB.
Whole job under CI parameters: 6 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
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.
Makes gradient-checkpointing schedules replayable from a supported entry point, and
puts the one verified CCT schedule into CI.
Stacked on #54: based on its branch, so the diff below is against it.
Why
The decision of which activations to drop and regenerate comes from a solve over the
training graph. Replaying that decision on the graph Deeploy actually deploys was
only possible from outside the tree, as a sitecustomize patch over
trainingUtils._mockSchedulerdriven bySEQ_PATH/SHARE_CONST/TIGHTENenvironment variables. Nothing about it could run in CI.
What
testMVPTraining.py --recomputeSchedule <json>takes{"seq": [[node, is_recompute]]}in run order. Each recompute becomes a short-lived clone, and consumers scheduled
after it read the clone, so the original's output dies at its last forward use
instead of staying live across the backward pass. The flag is threaded through
deeployTrainingRunnerand the GAP9 tiled runner, andcreate_test_configgrows atraining_recompute_scheduleso CI configs can set it.The parts that are not obvious, each of which was a failure before it was handled:
puts an empty layer into
layerBindingand fails far from the cause, so namelookup prefers a node that still produces something.
replays the order cached from the first; cloning again would produce a second set
of clones for the same recomputes.
constant duplicated per consumer during folding produces a tensor its consumer
reads, so appending it after that consumer leaves the input unresolved and context
lookup fails.
every consumer of an original to a clone. Either leaves a node nothing reads, whose
output cleanup strips. Both are pruned to a fixpoint, since dropping one can orphan
its producer.
the full constant again per clone, so recomputing a node that reads a weight grows
the persistent region instead of shrinking the peak: on the int8 QLoRA CCT, 117
clones took persistent from 662 KB to 938 KB.
The failure mode this refuses
A schedule is keyed on node names and lowering renames nodes. Replaying a solve
against a graph it did not come from drops every recompute and quietly runs the
default order under a name claiming to be checkpointed, which a pass/fail CI result
cannot distinguish from the real thing. A replay matching under 90% of its entries
raises instead.
CI
recompute_checkmate.jsonThe extra cycles are the point: checkpointing buys peak memory with compute, so this
runs alongside the ordinary singlebuffer entry rather than replacing it, in its own
job so a failure is attributable.
Not included
The four operating points from the memory-budget sweep (peak 1669 / 1575 / 1383 /
1255 KB at budget fractions 0.9 to 0.6) are not here. Their activation figures are
LP predictions validated against codegen, and their latencies are summed per-op
cycles; neither came from a gvsoc run, and the sequences themselves were never
emitted, so there is nothing to replay. Adding them would install unverified
configurations as regression baselines. Producing them needs a solve per point plus a
gvsoc run each.
🤖 Generated with Claude Code