Skip to content

(perf) core: fuse a squeezed unit matmul axis and a unicast Mul+Add pair - #2728

Open
czoli1976 wants to merge 3 commits into
sonos:mainfrom
czoli1976:perf/fuse-unicast-and-squeeze
Open

(perf) core: fuse a squeezed unit matmul axis and a unicast Mul+Add pair#2728
czoli1976 wants to merge 3 commits into
sonos:mainfrom
czoli1976:perf/fuse-unicast-and-squeeze

Conversation

@czoli1976

@czoli1976 czoli1976 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Three changes that cut copies and node count in the optimised graph. All are
pure plumbing — outputs are bit-identical on every model tested.

absorb_squeeze refused to drop a unit m or n axis

OptMatMul::absorb_squeeze bailed on any reshape that removed the m or n axis.
But a pure squeeze only ever removes unit axes, and the store already handles a
None axis: compute_strides reads a zero stride for it and the extent falls
back to 1, which is exactly what a squeezed unit axis means. So the guard was
turning down reshapes it could have absorbed.

It costs two nodes, not one. A [1, m] GEMV output reshaped to [m] kept its
IntoShape, and that node then sat between the matmul and its bias add,
blocking the OptBinUnicast(Add)AddUnicast-in-the-store fusion that would
otherwise have fired.

The guard is dropped, both axes now go through a squeeze_c_axis helper, and
rm_c_axis tracks the change into the fused specs' own store specs — including
AddUnicast's, which previously was not updated at all (unreachable while the
guard stood).

A unicast Mul feeding a unicast Add walked the accumulator twice

x * g then + b reads and writes the whole tensor twice for arithmetic that
fits in one traversal. OptBinUnicast::fuse now folds the pair into
OptMulAddUnicast when both unicast operands share a period, so a single walk
covers them. It is codegen-only, so nothing new has to serialise.

This is the affine tail of a normalisation and the gate update of a GRU cell —
it fires on LayerNormalization.y_internal and grucell/Add_2 among others.

MoveAxis copied the tensor twice

AxisOp::change_tensor's Move arm did tensor.clone().move_axis(..). But
move_axis consumes its input and goes through permute_axesTensor::from_datum,
which allocates a fresh natural-strided tensor and copies into it. The clone was a
second, redundant full copy on every MoveAxis node.

Unlike the two rules above this one is not shape-specific — it applies to any
transpose in any model, which is why dtln and the DeepFilterNet3 submodels move
at all here.

Measured

p50 ms/frame, median of 5 interleaved runs, single-threaded, aarch64, against
this PR's merge base.

model main this PR
dpdfnet2_8khz 1.449 1.380 +4.8%
dpdfnet2 1.329 1.277 +4.0%
baseline (DPDFNet, no GRU) 0.464 0.446 +3.8%
dpdfnet8 3.940 3.794 +3.7%
dpdfnet4 2.198 2.130 +3.1%
dpdfnet8_8khz 3.791 3.684 +2.8%
dpdfnet8_48khz_hr 6.695 6.516 +2.7%
dpdfnet2_48khz_hr 2.555 2.497 +2.3%
gtcrn 0.683 0.680 +0.5%
DeepFilterNet3 enc 0.1263 0.1258 +0.4%
dtln_model_2 0.0529 0.0527 +0.4%
dtln_model_1 0.0312 0.0312 0.0%
DeepFilterNet3 erb_dec 0.1057 0.1057 0.0%
DeepFilterNet3 df_dec 0.0984 0.0991 −0.7%
gtcrn_simple 0.685 0.696 −1.6%

The last two rows are noise, not regressions: gtcrn_simple is the same graph as
gtcrn (+0.5%) and df_dec runs in 98 µs, where a 0.7 µs swing is a rounding
artifact of the median.

The wins concentrate on DPDFNet, whose
Gemm-based GRU cells emit exactly the [1, m]-GEMV-then-reshape-then-bias chain
the first rule unblocks. On dpdfnet8_48khz_hr that is 1901 nodes down to 1103,
with OptAddUnicast 148 → 50 and IntoShape 184 → 154. GTCRN and DTLN do not
form the chain and land inside noise, as expected.

Outputs compared whole-stream, every frame: max absolute difference exactly
0
on all nine models above, including the two that gain nothing.

Full workspace suite green, cargo fmt and cargo clippy clean. No new
unsafe.

🍍

@czoli1976

Copy link
Copy Markdown
Contributor Author

Not bad at all, @kali

kali
kali previously approved these changes Aug 26, 2026
@czoli1976

Copy link
Copy Markdown
Contributor Author

@kali , sorry, folding in an improvement

@github-actions

Copy link
Copy Markdown

🔴 Bench vs main — 1 speed regression(s) · ⚠️ 3 secondary

Reference: 2026-08-26 morning nightly run (0d old) · full report → run

Speed — evaltime · prefill · decode

Δ metric device main → PR
🔴 +5.5% arm_ml_kws_cnn_m
evaltime · pass
cortex-a9 9.23 ms → 9.74 ms

Improvements

Δ metric device main → PR
🟢 -8.4% nemotron_3_5_asr_streaming_0_6b_f32f32_preprocessor_pulse100ms
evaltime · cpu
i9-11900kb_rtx-4060 0.0831 ms/pulse
0.000831 RTF → 0.0762 ms/pulse
0.000762 RTF
🟢 -6.5% mobilenet_v2_1
evaltime · pass_mt
apple-m1-max 28.6 ms → 26.8 ms
🟢 -6.3% mobilenet_v1_1
evaltime · pass
apple-m1-max 18 ms → 16.9 ms
🟢 -6.3% inceptionv1q
evaltime · pass
beaglev-ahead 2.75 s → 2.58 s
⚠️ 3 secondary regression(s)
Δ metric device main → PR
⚠️ +42.7% arm_ml_kws_cnn_m
load · pass
cortex-a9 82 ms → 117 ms
⚠️ +38.3% arm_ml_kws_cnn_m
load+optimize · pass
cortex-a9 128 ms → 177 ms
⚠️ +5.8% en_tdnn_15M
RSS @ ready · 2600ms
cortex-a55 111 MB → 117 MB

@czoli1976

Copy link
Copy Markdown
Contributor Author

Added a third commit, core: drop a redundant copy in MoveAxis.

AxisOp::change_tensor's Move arm did tensor.clone().move_axis(..), but
move_axis consumes its input and goes through permute_axes
Tensor::from_datum, which allocates a fresh natural-strided tensor and copies
into it. So the clone was a second full copy of the data on every MoveAxis,
83 of them per frame on the model I was profiling.

It is smaller than it looks: the clone is a flat memcpy while the copy that
remains is the strided one, so MoveAxis goes 0.256 → 0.219 ms/i rather than
halving. But unlike the other two commits it is not shape-specific — it applies
to any transpose, which is why the DTLN and DeepFilterNet3 rows move at all in
the table.

The perf table in the description is re-measured with all three commits in.

@github-actions

Copy link
Copy Markdown

⚠️ Bench vs main — no speed regressions · 11 secondary regression(s)

Reference: 2026-08-26 morning nightly run (0d old) · full report → run

Speed — evaltime · prefill · decode

no inference-speed regressions

Improvements

Δ metric device main → PR
🟢 -9.2% nemotron_3_5_asr_streaming_0_6b_f32f32_preprocessor_pulse100ms
evaltime · cpu
i9-11900kb_rtx-4060 0.0831 ms/pulse
0.000831 RTF → 0.0755 ms/pulse
0.000755 RTF
🟢 -6.3% mobilenet_v1_1
evaltime · pass
apple-m1-max 18 ms → 16.9 ms
🟢 -6.1% mobilenet_v2_1
evaltime · pass_mt
apple-m1-max 28.6 ms → 26.9 ms
⚠️ 11 secondary regression(s)
Δ metric device main → PR
⚠️ +14.6% arm_ml_kws_cnn_m
load · pass
cortex-a9 82 ms → 94 ms
⚠️ +13.9% hey_snips_v4_model17
load · 2sec
cortex-a7 4.21 s → 4.79 s
⚠️ +13.8% hey_snips_v4_model17
load+optimize · 2sec
cortex-a7 4.98 s → 5.66 s
⚠️ +12.0% mobilenet_v2_1
RSS @ ready · pass_mt
cortex-a53 52.5 MB → 58.8 MB
⚠️ +10.4% mobilenet_v2_1
RSS @ ready · pass
cortex-a55 52.4 MB → 57.8 MB
⚠️ +9.6% mobilenet_v2_1
RSS @ ready · pass_mt
cortex-a55 52.5 MB → 57.6 MB
⚠️ +8.3% arm_ml_kws_cnn_m
load · pass
cortex-a53 48 ms → 52 ms
⚠️ +6.3% mobilenet_v2_1
RSS @ load · pass
cortex-a55 67.5 MB → 71.8 MB
⚠️ +6.1% mobilenet_v2_1
RSS @ load · pass_mt
cortex-a53 67.7 MB → 71.8 MB
⚠️ +5.6% mobilenet_v2_1
RSS @ load · pass_mt
cortex-a55 67.5 MB → 71.3 MB
⚠️ +5.4% arm_ml_kws_cnn_m
load+optimize · pass
cortex-a53 74 ms → 78 ms

@kali

kali commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

⚠️⚠️⚠️ Just rebased! ⚠️⚠️⚠️

@kali
kali force-pushed the perf/fuse-unicast-and-squeeze branch from e181d95 to 3e44da0 Compare August 26, 2026 15:17
czoli1976 and others added 3 commits August 28, 2026 14:44
`absorb_squeeze` refused any reshape that dropped the m or n axis, so a matmul
whose n axis is the unit axis of a `[1, m]` output kept a separate `IntoShape`,
which in turn blocked the successor bias add from fusing into the store. A pure
squeeze only ever removes unit axes, and the store already reads a zero stride
and an extent of 1 for a `None` axis, so drop the guard and let the squeezed
axis become `None`, tracking it through the fused specs' own store specs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A `Mul` and an `Add` over the same accumulator each walked it end to end,
reading and writing the whole tensor twice for arithmetic that fits in one
traversal. Fuse the pair into `OptMulAddUnicast` when both unicast operands
share a period, so one walk covers them together.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`move_axis` consumes its input and materialises the permuted result into a
freshly allocated tensor, so cloning beforehand copied the data twice.

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

kali commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

⚠️⚠️⚠️ Just rebased! ⚠️⚠️⚠️

@kali
kali force-pushed the perf/fuse-unicast-and-squeeze branch from 3e44da0 to 247da09 Compare August 28, 2026 12:44
@github-actions

Copy link
Copy Markdown

⚠️ Bench vs main — no speed regressions · 11 secondary regression(s)

Reference: 2026-08-28 morning nightly run (0d old) · full report → run

Speed — evaltime · prefill · decode

no inference-speed regressions

Improvements

Δ metric device main → PR
🟢 -19.8% nemotron_3_5_asr_streaming_0_6b_f32f32_preprocessor_pulse100ms
evaltime · cpu
apple-m1-max 0.132 ms/pulse
0.00132 RTF → 0.106 ms/pulse
0.00106 RTF
🟢 -6.1% mobilenet_v2_1
evaltime · pass_mt
apple-m1-max 28.5 ms → 26.8 ms
🟢 -5.3% mobilenet_v1_1
evaltime · pass
apple-m1-max 17.8 ms → 16.9 ms
⚠️ 11 secondary regression(s)
Δ metric device main → PR
⚠️ +38.7% arm_ml_kws_cnn_m
load · pass
beaglev-ahead 75 ms → 104 ms
⚠️ +27.9% arm_ml_kws_cnn_m
load+optimize · pass
beaglev-ahead 111 ms → 142 ms
⚠️ +21.0% arm_ml_kws_cnn_m
load · pass
cortex-a9 81 ms → 98 ms
⚠️ +14.2% arm_ml_kws_cnn_m
load+optimize · pass
cortex-a9 127 ms → 145 ms
⚠️ +12.5% mobilenet_v2_1
RSS @ ready · pass_mt
cortex-a53 52.7 MB → 59.3 MB
⚠️ +10.8% mobilenet_v2_1
RSS @ ready · pass_mt
cortex-a55 52.7 MB → 58.4 MB
⚠️ +9.9% mobilenet_v2_1
RSS @ ready · pass
cortex-a55 53.1 MB → 58.4 MB
⚠️ +8.5% arm_ml_kws_cnn_m
load · pass
cortex-a53 47 ms → 51 ms
⚠️ +6.9% mobilenet_v2_1
RSS @ load · pass_mt
cortex-a53 67.5 MB → 72.2 MB
⚠️ +5.5% mobilenet_v2_1
RSS @ load · pass
cortex-a55 67.6 MB → 71.3 MB
⚠️ +5.1% mobilenet_v2_1
RSS @ load · pass_mt
cortex-a55 67.9 MB → 71.3 MB

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.

2 participants