Skip to content

fix(training): deploy the MLperf-compliant DS-CNN and autoencoder fixtures - #70

Merged
runwangdl merged 4 commits into
develfrom
fix/mlperf-compliant-dscnn-ae-fixtures
Sep 12, 2026
Merged

runwangdl merged 4 commits into
develfrom
fix/mlperf-compliant-dscnn-ae-fixtures

Conversation

@runwangdl

@runwangdl runwangdl commented Sep 10, 2026 •

Copy link
Copy Markdown
Owner

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/.

Benchmark Reference (shipped int8 graph) Before After
KWS DS-CNN, 1x49x13→12, 22,160 par 1x25x10, 16 ch, 2,732 par, 0.072 M MACs 1x49x10, 64 ch, 23,180 par, 2.657 M MACs
AD Deep AE, 640→640, 10 Gemm, 265,106 par 128→128, 29,120 par 640→640, 10 Gemm, 267,928 par

DS-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 640 is 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:

model params MACs MACs/param
Autoencoder 268 K 0.26 M 0.99
DS-CNN-S 23 K 2.66 M 115
ResNet-8 78 K 12.5 M 160
MobileNetV1 214 K 7.49 M 35
CCT-2 284 K 35.3 M 124

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%). 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 (allocator 2 is cluster L1; allocator 1 is L2 — see pi_malloc.h).

DS-CNN keeps only its 128000 L1 entry: at 8× the old fixture it has nothing left to gain from the smaller arena.

Also in here

  • Drops two per-model comments that quoted cycle counts measured on the old fixtures (-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.
  • Guards the per-mini-batch LOSSLIVE printf with POWER_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 average run_power_auto.sh reports — 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 by check_losses.

Measured

GAP9 / gvsoc, Errors: 0 out of 4 throughout:

model train cycles opt cycles deployment
DS-CNN-S 40,641,015 339,228 L2 single-buffer, --l1 128000
Autoencoder 21,717,385 9,854,185 L3 promote + DB, --l1 122000

Full gap9_tiled and training sweep 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:

Onnx4Deeploy.py -model DSCNN-S        -mode train -o <...>/DSCNN/dscnn_train
Onnx4Deeploy.py -model Autoencoder-AD -mode train -o <...>/Autoencoder/autoencoder_train

Note for anyone re-running these locally

PYTEST_XDIST_WORKER isolates 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 L3 hex/ 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 --l2 value recovers it. minimalloc fails at the runner's 1,991,744 B capacity. It moves to L3_SINGLEBUFFER_TRAINING_MODELS there as well.

Also drops DS-CNN's 64000 L1 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:

file references in CI
test_gap9_tiled_config.py 5 yes
test_siracusa_tiled_config.py 4 yes
benchmark_training.py 3 no — standalone script, L1 values already diverge from the test configs; left alone

runwangdl and others added 3 commits September 10, 2026 14:45
…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
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
runwangdl merged commit e707568 into devel Sep 12, 2026
29 checks passed
@runwangdl
runwangdl deleted the fix/mlperf-compliant-dscnn-ae-fixtures branch September 13, 2026 01:24
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>
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