Repository navigation
fix(training): deploy the MLperf-compliant DS-CNN and autoencoder fixtures - #70
Merged
Merged
Conversation
…tures
Two of the five MLperf Tiny training fixtures were not the reference model.
Regenerated with the exporters from Onnx4Deeploy#24 and checked against the
int8 reference graphs this repo already ships in Tests/Models/MLPerf/.
DS-CNN. The KWS fixture was the "xs" PULP variant: 25x10 MFCC, 16 channels,
2,732 parameters, 0.072 M MACs. The MLperf reference is DS-CNN-S at 49x10 with
64 channels. It is now 23,180 parameters and 2.657 M MACs, which is the
published figure -- reaching it also needed the stem's padding fixed, since the
reference stem is Conv2D(64,(10,4),strides=2,padding='same') and emitted a 25x5
map where ours emitted 20x4.
Autoencoder. The AD fixture was a 128-dim toy: 128 -> 64 -> 32 -> 64 -> ... ->
128, 29,120 parameters. The reference is 640 -> 128x4 -> 8 -> 128x4 -> 640 with
BatchNorm on every hidden layer, and the 640 is 128 log-mel bins times 5 stacked
frames, not one frame. It is now 267,928 parameters over 10 Gemm layers, against
265,106 initializer elements in the shipped int8 reference (the difference is
the BatchNorm affine parameters, which that graph has folded).
The autoencoder consequently moves from the L2 lists to the L3 ones. This is not
a tuning choice: 1046.6 KB of weights plus an equally large gradient
accumulation buffer is 2098 KB of graph-input tensors against GAP9's 1536 KB L2,
so minimalloc fails even at --l2 1572864. Being the only pure MLP among the five
benchmarks it has 0.99 MACs per parameter where the convolutional ones have
115-160, so it is the one model whose parameters rather than its activations set
the footprint. Its best measured config is L3 + promote + double-buffer at
--l1 122000 with cc_stack 4096: 21.7M cycles per 4 mini-batches against 36.6M
for L3 single-buffer. 122000 rather than the 128000 it used on L2 because an L3
model must leave L1 for the cluster stacks -- 122000 + 4096 + 512*8 = 130192 <
131072; at 128000 gvsoc aborts with "Allocation failed for allocator 2".
DS-CNN keeps only its 128000 L1 entry: at 8x the old fixture it has nothing left
to gain from the smaller arena.
Also drops two stale per-model comments that quoted cycle counts measured on the
old fixtures, and guards the per-mini-batch LOSSLIVE printf with
POWER_MEASUREMENT. That printf sits inside the training loop, between two GPIO
pulses, so it lands inside the first->last GPIO-high window a PPK2 whole-loop
average is taken over. It does not affect the GPIO-high-only average that
run_power_auto.sh reports, but it does inflate the window average, which is the
one energy per step has to be computed from. Every loss is still printed after
the loop by check_losses.
Measured on GAP9/gvsoc, Errors: 0 out of 4 throughout:
model train cycles opt cycles deployment
DS-CNN-S 40,664,203 335,216 L2 single-buffer, --l1 128000
Autoencoder 21,717,385 9,854,185 L3 promote+DB, --l1 122000
Requires Onnx4Deeploy#24 to regenerate:
Onnx4Deeploy.py -model DSCNN-S -mode train -o <...>/dscnn_train
Onnx4Deeploy.py -model Autoencoder-AD -mode train -o <...>/autoencoder_train
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vb2GVs3qbiyLV1KkV2baN
The fixture swap in the previous commit changed the autoencoder for every platform, but only the GAP9 config was updated. Siracusa carries its own training lists and referenced the autoencoder in two of them, so its L2 single-buffer and L2 double-buffer jobs would have broken. Siracusa's L2 is 2 MB. The MLperf Tiny AD reference needs 2,148,548 B of graph-input tensors alone -- 1046.6 KB of weights plus an equally large gradient-accumulation buffer -- which is 51 KB over the whole level before a single activation, so no --l2 value recovers it. minimalloc fails at the runner's 1,991,744 B capacity. Also drops DS-CNN's 64000 L1 entry to match GAP9 (at 8x the old fixture it has nothing left to gain from the smaller arena), and rewrites the comment that justified the 32 KB autoencoder double-buffer variant: it argued that 32 KB forced "~8 of 55 ops" to 2-4 tiles so DB pipelining would fire, which was measured on the old 29,120-parameter fixture. The reference model is 205 nodes and no longer fits L2 at all. Note for reviewers: benchmark_training.py also references both fixtures, but it is a standalone benchmark script outside CI whose L1 values already diverge from the test configs, so it is left alone here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vb2GVs3qbiyLV1KkV2baN
116000 was chosen because CI trains four mini-batches and their accumulation buffers left the L1 allocator 118 KB. With the accumulator forked across the cluster (#69) that budget holds at 122000, and the 6 KB is worth a great deal here: 55.40M -> 43.16M cycles per step, -22.2%, Errors 0 out of 4. It also settles a comparison that read wrong. At 116000 the fully on-chip deployment was slower than every L3 configuration, which suggests going off-chip is free. At an equal L1 it is the fastest of them: configuration l1 per-step L2 on-chip 122000 43.16M L3 + promote + double-buf 122000 45.68M L3 double-buffer 122000 46.85M L3 single-buffer 122000 49.17M L2 on-chip 116000 55.40M The L1 budget, not the memory level, was doing the work. L2 still cannot be double-buffered: the arena is 1,350,236 B of the 1.5 MB and doubled staging does not fit ("Allocation failed for allocator 1" -- allocator 1 is L2, 2 is cluster L1, per pi_malloc.h). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vb2GVs3qbiyLV1KkV2baN
runwangdl
force-pushed
the
fix/mlperf-compliant-dscnn-ae-fixtures
branch
from
September 10, 2026 14:46
e88f873 to
d0ab64a
Compare
The previous commit raised this budget to 122000 on the strength of a
-22% step time. The cluster-L1 allocator has 118,672 B free once the
runtime and the cluster stacks are up, and at a 122000 budget the
random-max tiling search can land on a 121,984 B arena. That does not
fit, pi_l1_malloc returns NULL, nothing checks it, and the run continues
on a wild pointer until some load faults:
Allocation failed for allocator 2, dumping allocator state:
Free Block at 10003070, size: 1CF90 <- 118,672 B
...
Invalid access (pc: 0x1c017eca, offset: 0xbef885da)
It is intermittent, because a different search result fits, which is why
it survived several local runs and then failed in CI. The same constraint
is already written down two entries below, for CCT_QLORA_FT: four
mini-batches of accumulator buffers leave the allocator 118 KB and a
122000 arena does not fit. It simply was not applied here.
118000 keeps the arena under the ceiling by construction while retaining
almost all of the gain.
Also check the return value. The SDK prints "Allocation failed for
allocator 2" and carries on; the generated code then computes every tile
address from a NULL base, so the failure surfaces much later as an
unrelated Invalid access -- or not at all, if the wild reads happen to
land on mapped memory. Both GAP9 L1 allocation templates now abort at the
point of failure with the requested size, so the log names the cause.
runwangdl
added a commit
that referenced
this pull request
Sep 13, 2026
#75) * feat(gap9): untiled baseline, and fix two defects in PR #21's approach Adds DEEPLOY_L1_AS_L2, the compile-time switch that turns a tiled deployment into the untiled baseline the tiling ablation needs. On what "untiled" means here: PULP cluster cores cannot dereference HyperRAM addresses, so an untiled L3 deployment is not "no movement". It is a single tile whose L1-annotated buffers are physically allocated in FC L2, with the cluster computing over the L2 fabric. The switch therefore redirects pi_l1_malloc to pi_l2_malloc and replaces the mchan pushes with memcpy. PR #21 had the right idea and I was wrong to call its memcpy substitution lazy: the mchan hardware routes `loc` into L1 banks unconditionally, so with L1-as-L2 there is genuinely no DMA left to program. Two things were missing from it: - only mchan_transfer_push_1d was substituted, so any tile taking the 2-D path hung. push_2d is substituted too. - the define was set with target_compile_definitions(${ProjectId} PRIVATE), but ProjectId compiles only CycleCounter.c and deeploytraintest.c. Every L1 allocation and every mchan call lives in the GENERATED network code, which is a separate target (${NETWORK_LIB} / optimizer_network). The define never reached the code it was meant to switch, and the two arms produced the same binary -- DS-CNN measured 3,044,109 untiled against 3,044,050 tiled, a 59-cycle "difference". It is now a directory-scope add_compile_definitions in DeeployTest/CMakeLists.txt. deeploylib is an INTERFACE target, so it takes INTERFACE rather than PUBLIC. Measured once the define actually applied (GAP9, 8 cores, gvsoc, every run Errors: 0 out of 4): network tiled untiled slowdown Autoencoder 2,504,870 62,385,885 24.9x DS-CNN 3,044,050 45,917,437 15.1x CCT-2 256,721,885 2,093,477,099 8.2x MobileNetV1 177,150,391 2,171,487,469 12.3x ResNet-8 184,286,767 1,070,880,302 5.8x The optimizer step is 23-31x slower across all five, a near constant: SGD is elementwise and memory-bound, so that column reads out the TCDM-vs-L2 access cost with nothing to hide it. The training column varies with arithmetic intensity instead -- ResNet-8 lowest because convolutions over large feature maps reuse each fetched byte many times, the autoencoder highest because it is a pure MLP. (The Autoencoder and DS-CNN rows above predate PR #70's MLperf-compliant fixtures and are being re-measured.) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vb2GVs3qbiyLV1KkV2baN * style: isort and yapf the untiled-baseline changes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vb2GVs3qbiyLV1KkV2baN --------- Co-authored-by: Claude Opus 5 (1M context) <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.
What
Two of the five MLperf Tiny training fixtures were not the reference model. Regenerated with the exporters from pulp-platform/Onnx4Deeploy#24 and checked against the int8 reference graphs this repo already ships in
Tests/Models/MLPerf/.1x49x13→12, 22,160 par1x25x10, 16 ch, 2,732 par, 0.072 M MACs1x49x10, 64 ch, 23,180 par, 2.657 M MACs640→640, 10 Gemm, 265,106 par128→128, 29,120 par640→640, 10 Gemm, 267,928 parDS-CNN also needed the stem's padding fixed: the reference stem is
Conv2D(64,(10,4),strides=2,padding='same')and emits a 25x5 map where ours emitted 20x4. With that, MACs land on the published 2.657 M.Autoencoder — the
640is 128 log-mel bins × 5 stacked frames, not a single frame. Its 267,928 parameters against 265,106 initializer elements in the shipped int8 graph differ by exactly the BatchNorm affine parameters, which that graph has folded.The autoencoder moves from L2 to L3
Not a tuning choice. 1046.6 KB of weights plus an equally large gradient-accumulation buffer is 2098 KB of graph-input tensors against GAP9's 1536 KB L2 — minimalloc fails even at
--l2 1572864.It is the only pure MLP among the five benchmarks, so every weight is used exactly once:
That ratio is why it is the one benchmark whose parameters, not its activations, set the footprint.
Best measured config: L3 + promote + double-buffer at
--l1 122000,cc_stack 4096— 21.7M cycles per 4 mini-batches against 36.6M for L3 single-buffer (−40%).122000rather than the128000it used on L2 because an L3 model must leave L1 for the cluster stacks:122000 + 4096 + 512*8 = 130192 < 131072. At 128000 gvsoc aborts withAllocation failed for allocator 2(allocator 2 is cluster L1; allocator 1 is L2 — seepi_malloc.h).DS-CNN keeps only its
128000L1 entry: at 8× the old fixture it has nothing left to gain from the smaller arena.Also in here
-38.5%/-33.7%figures taken at 0.48M and 0.80M cyc/step, against 10.2M for DS-CNN-S today). Stale numbers in comments are worse than none — code that is wrong fails, a comment that is wrong gets believed.LOSSLIVEprintf withPOWER_MEASUREMENT. It sits inside the training loop between two GPIO pulses, so it lands inside the first→last GPIO-high window a PPK2 whole-loop average is taken over. It does not affect the GPIO-high-only averagerun_power_auto.shreports — the previously recorded power figures are unaffected — but it does inflate the window average, which is the one energy-per-step has to be computed from. Every loss is still printed after the loop bycheck_losses.Measured
GAP9 / gvsoc,
Errors: 0 out of 4throughout:--l1 128000--l1 122000Full
gap9_tiled and trainingsweep run locally: L2 single-buffer 4/4, L3 single-buffer 9/9, L3 double-buffer 3/3, L3 promote+DB 4/4.Regenerating the fixtures
Requires pulp-platform/Onnx4Deeploy#24:
Note for anyone re-running these locally
PYTEST_XDIST_WORKERisolates the build directory but not the generated one —TEST_GAP9/Tests/...is shared across configurations. Running the same model at L3 and then at L2 leaves the L3hex/readfs artifacts behind and the L2 build picks them up. Wipe the model's generated directory when switching deployment level.Relationship to #69
Independent. #69 makes the gradient accumulator 6× unrolled and 8-core, which is what takes the autoencoder from 31.8M to 21.7M; this PR is what makes it the right model in the first place.
🤖 Generated with Claude Code
https://claude.ai/code/session_012vb2GVs3qbiyLV1KkV2baN
Siracusa (second commit)
The fixture swap changes the autoencoder for every platform, not just GAP9. Siracusa keeps its own training lists and referenced the autoencoder in two of them, so
siracusa-training-tiled-l2(single- and double-buffer) would have broken.Siracusa's L2 is 2 MB = 2,097,152 B. The autoencoder's graph-input tensors alone are 2,148,548 B — over the whole level by 51 KB before a single activation, so no
--l2value recovers it. minimalloc fails at the runner's 1,991,744 B capacity. It moves toL3_SINGLEBUFFER_TRAINING_MODELSthere as well.Also drops DS-CNN's
64000L1 entry to match GAP9, and rewrites the comment justifying the 32 KB autoencoder double-buffer variant — it argued 32 KB forced "~8 of 55 ops" to 2-4 tiles so DB pipelining would fire, measured on the old 29,120-parameter fixture. The reference model is 205 nodes and no longer fits L2 at all.Reference sites for these two fixtures, for anyone changing them again:
test_gap9_tiled_config.pytest_siracusa_tiled_config.pybenchmark_training.py