Faster Mel-Band RoFormer on an RTX 3050: baseline, profile, gate, and a custom runtime
Mel-Band RoFormer vocals + instrumental separation on one RTX 3050 6 GB laptop GPU (victoria): 9.185 s for the 60 s clip vs vanilla 22.194 s; 2.42x speedup at the original chunking settings. An explicit speed/quality option uses 25 % overlap: 5.933 s, 3.74x, with average truth-SDR losses of 0.082 dB dev and 0.042 dB test; the final section explains the revised policy. The optimisation log runs through step 15; the closing "where it stands" section collects the numbers.
The baseline and how it was measured
What. The official
KimberleyJensen/Mel-Band-Roformer-Vocal-Model repo at
commit 25f44ff, unmodified, running the two-stem
becruily/mel-band-roformer-deux checkpoint on a laptop
card: chunked overlap-add, batch 1, fp16 autocast.
The one thing that had to be decided. The deux
checkpoint's YAML has no inference.chunk_size, which the
official demix_track requires. The yaml does carry
audio.chunk_size: 573300 (13 s), so the adapter
(baseline/runner.py) uses that. Vanilla therefore means: 13
s chunks, 50 % overlap (num_overlap: 2), batch 1, fp16
autocast.
Result.
| latency, 60 s clip | 22.194 s (median of 5; min 22.183, max 22.199) |
| repeat run | 22.196 s |
| real-time factor | 0.370 |
| peak VRAM | 2 179 MiB |
Measurement protocol (gate/run.py), and
the reason for each rule:
- Audio already decoded, in host memory. MP3 decoding is CPU work unrelated to the model; including it would dilute every GPU improvement.
- Clock covers host->GPU copy, all chunks, overlap-add, GPU->host copy. That is what a caller waits for.
torch.cuda.synchronize()before stopping the clock. CUDA calls return before the GPU finishes; without a sync you time how fast Python can queue work.- 2 warm-up calls, then 5 timed, report the median. The first call pays one-off costs (kernel selection, allocator growth, lazy init). Median, not mean, so one disturbed run does not move the number.
- Cool-down to 60 °C before timing, temperatures recorded. A laptop GPU throttles when hot, and that must not be charged to the code under test.
Surprise worth keeping for the article. The baseline is extraordinarily stable: ten timed runs across two processes span 20 ms out of 22 s (0.1 %). The GPU is doing the same deterministic work with nothing else competing. That makes small improvements measurable — a 1 % gain is ten times the noise.
Where the time goes: the full hotpath
Milliseconds per 13 s chunk, and share of in-model time.
| stage | ms / chunk | share | what it is |
|---|---|---|---|
| rotary embedding | 656.6 | 35.6 % | rotate q and k by position; 48 calls per chunk |
| RMSNorm (3 per transformer) | 324.8 | 17.6 % | attn pre-norm, ff pre-norm, output norm; 72 calls |
| attention kernel, time axis | 200.0 | 10.8 % | 60 sequences of 1 301 frames |
| attention kernel, freq axis | 30.8 | 1.7 % | 1 301 sequences of 60 bands |
to_qkv linear |
124.9 | 6.8 % | 256 -> 1 536 GEMM |
| feed-forward up (256 -> 1 024) | 97.3 | 5.3 % | GEMM |
| feed-forward down (1 024 -> 256) | 65.4 | 3.5 % | GEMM |
| GELU | 62.7 | 3.4 % | one elementwise pass over the 1 024-wide tensor |
| residual adds | 76.3 | 4.1 % | attn(x) + x, ff(x) + x |
to_out linear |
33.7 | 1.8 % | 512 -> 256 GEMM |
to_gates linear |
31.4 | 1.7 % | 256 -> 8 GEMM, output in fp32 |
| gate multiply + head reshapes | 32.7 | 1.8 % | sigmoid gate applied to attention output |
| axis swaps between blocks | 30.7 | 1.7 % | rearrange/pack copies when switching time
<-> freq |
| trunk total | 1 767 | 95.7 % | |
| mask estimators (2 stems) | 57.3 | 3.1 % | 120 small per-band MLPs |
| mask apply | 10.1 | 0.55 % | complex mask, scatter-add average over overlapping bands |
| band split | 4.8 | 0.26 % | 60 x (norm + linear) |
| iSTFT | 4.2 | 0.23 % | |
| mel-band gather | 2.5 | 0.13 % | |
| STFT | 0.6 | 0.03 % |
Kernel class breakdown
The same time, sorted by the kind of GPU work:
| kernel class | share | bound by |
|---|---|---|
| elementwise multiply | 25.8 % | memory bandwidth |
| copy / cat | 16.0 % | memory bandwidth |
| elementwise add | 9.1 % | memory bandwidth |
| elementwise divide | 5.1 % | memory bandwidth |
| other elementwise / reductions | 4.1 % | memory bandwidth |
| GELU | 3.4 % | memory bandwidth |
| negate (rotary) | 2.5 % | memory bandwidth |
| norm reduction (RMSNorm) | 2.5 % | memory bandwidth |
| memory-bound total | 68.5 % | |
| GEMM (tensor cores) | 17.9 % | compute |
| attention kernel | 12.4 % | compute + memory |
Two thirds of the GPU's time is spent moving the same tensors through memory, one trivial operation at a time. Only 30 % is the matrix arithmetic the model is "about." That is the central finding, and it sets the strategy.
The bottlenecks with traces
Rotary embedding — 35.6 %

The whole request: six chunks back to back across the 22 s run.

One 13 s chunk on the GPU stream: stft to istft.

Time-axis attention: norm, to_qkv, rotary(q), rotary(k), sdpa, gates, to_out.

Time axis: one rotary embedding call (applied to q, then again to k).
The implementation computes, for q and then again for k:
t = (t * freqs.cos() * scale) + (rotate_half(t) * freqs.sin() * scale)
return torch.cat((t_left, t, t_right), dim=-1)
On the GPU this is nine separate kernels, each a full pass over an 80 MB tensor (~1.5 ms each):
| pass | why it exists |
|---|---|
t * cos |
the actual rotation, first half |
* scale |
scale is 1.0: a full pass that changes nothing |
| negate | rotate_half builds (-x2, x1) |
| stack copy | rotate_half materialises the swapped pairs |
* sin |
the rotation, second half |
* scale |
again 1.0 |
| add | combine the halves |
| cat copy | t_left and t_right are empty; the cat
copies t for nothing |
| dtype copy | freqs.to(t) and autocast casts |
9 passes x 2 tensors x 24 attention layers = 432 full-tensor passes per chunk to apply what is mathematically one multiply-add per element. The cos/sin tables are also recomputed on every call. Both axes cost the same (328 ms each) because the tensor has the same number of elements either way.
RMSNorm — 17.6 %
The implementation uses:
F.normalize(x, dim=-1) * self.scale * self.gamma
Five kernels (norm reduction, clamp, divide, multiply by scale,
multiply by gamma) where one would do, 72 times per chunk. There is a
second cost hidden in it: gamma is an fp32 parameter, so
the last multiply promotes the result to fp32. The normalised tensor is
then twice as large (80 MB) and the next linear layer has to cast it
back to fp16 — 3.9 % of device time is exactly that round trip.
Time-axis attention — 10.8 %

Time axis: scaled_dot_product_attention, gates, to_out.
The official Attend picks the kernel by GPU model: flash
attention only on an A100 (compute capability 8.0), and on everything
else it disables flash and allows the "memory-efficient"
fmha_cutlass kernel. The 3050 is capability 8.6, so it gets
the slower kernel.
Feed-forward — 8.7 %

Time-axis feed-forward: norm, up-projection, gelu, down-projection, then the output norm.
Frequency axis — continues the pattern

Freq-axis attention: 1 301 sequences of 60 bands.

Freq axis: scaled_dot_product_attention, gates, to_out.

Freq-axis feed-forward and output norm.
Band split and mask estimation

Band split: norm + linear for each of 60 bands.

Mask estimator for stem 0: 60 per-band mlps.
Mask apply and ISTFT

After the mask heads: complex mask, scatter-add average, multiply, istft.
Between chunks

End of one chunk to the start of the next: overlap-add on the gpu, host loop.
The quality gate
Purpose. Every runtime is tested before its results are trusted. The gate runs a fixed suite of clips, measures fidelity against vanilla and quality against ground truth, applies thresholds, and either passes the experiment or rejects it. This makes small regressions visible and big bugs impossible to miss.
The clips.
| split | clips | purpose |
|---|---|---|
| headline | bad_guy_60s |
the latency number; fidelity only (no ground truth exists) |
| dev | 4 MUSDB18-HQ train tracks (30 s each) + 3 edge cases | may be inspected clip by clip while optimising |
| test | 5 MUSDB18-HQ test tracks (30 s each) | held out: only pass/fail and the aggregate are ever shown |
Edge cases, chosen because they take a different path through the chunker — the place a rewritten runtime is most likely to diverge: a 5 s clip (shorter than one chunk), a clip starting with 3 s of digital silence (zero-energy frames, NaN risk), and a peak-normalised clip with an odd sample count.
Two questions, two references.
- Fidelity — does the candidate still produce what vanilla produces? Reference: the frozen vanilla stems. Works on every clip.
- Quality — does the candidate still separate music well? Reference: MUSDB ground-truth stems. Vanilla scores 13.74 dB SDR on dev and 15.00 dB on test.
Both are needed. Fidelity alone would reject a runtime that is different but just as good (any change of overlap). Quality alone would accept a runtime that scores the same on average while glitching for one second.
The metrics, and what each one catches.
| metric | catches |
|---|---|
| SDR vs vanilla stem | overall waveform drift |
| SI-SDR | the same, ignoring a pure gain change (SDR << SI-SDR means a level bug) |
| worst 1-second-window SDR | a localised glitch — a bad chunk seam, a NaN patch — that a whole-clip average hides |
| MERT-v1-95M hidden-state cosine (pooled, and 1st-percentile frame) | perceptual/musical drift: does a music model still "hear" the same thing, at every moment |
| log-mel distance | spectral shape, independent of phase |
| loudness delta, finiteness, length, stems-sum-to-mix | plain bugs |
| SDR vs ground truth | actual separation quality |
Calibrating thresholds with yardsticks. "SDR above 30 dB" means nothing until you know what 30 dB sounds like for this model. Vanilla itself was run under four known perturbations and scored against the frozen reference (worst case over all clips and stems):
| vanilla variant | latency | SDR vs ref | worst 1 s | MERT frame p01 | truth SDR change |
|---|---|---|---|---|---|
| same again (noise floor) | 22.196 s | 120.9 dB | 89.6 dB | 1.0000 | 0.000 |
| fp32 instead of fp16 | 57.736 s | 39.5 dB | 24.8 dB | 0.9922 | -0.004 dB |
| 75 % overlap | 46.936 s | 31.1 dB | 7.1 dB | 0.9363 | +0.009 dB |
| no overlap | 9.284 s | 20.5 dB | 0.0 dB | 0.5039 | -0.095 dB |
Reading it: re-running vanilla reproduces it essentially exactly. Changing precision moves the output by a "40 dB-sized" amount that ground truth cannot see. Changing overlap moves the waveform much more (31 dB) while leaving separation quality untouched — two valid outputs that differ. Removing overlap is where real damage starts: quality drops 0.1 dB and some moments stop resembling the reference at all.
That gives two profiles, each threshold sitting between two yardsticks:
- strict — same chunking as vanilla. Must match vanilla about as closely as fp32 matches fp16 (SDR >= 36 dB, worst second >= 20 dB, MERT p01 >= 0.985, truth SDR drop <= 0.02 dB). This is the like-for-like speedup.
- open — chunking is free. May differ as much as 75 %-overlap vanilla does (SDR >= 28 dB, worst second >= 5 dB, MERT p01 >= 0.90) and must not lose more than 0.05 dB against ground truth. This is the best speedup. Vanilla with no overlap fails this profile on every line, by construction.
The gate was validated on the yardsticks before being trusted: repeat run and fp32 pass strict; 75 % overlap fails strict and passes open; no overlap fails both.
The optimisation log: steps 5–15
step 5 — a lean port with switches (exp001)
What. runtime/model.py is a rewrite of
the model that loads the official checkpoint unchanged (same parameter
names), and runtime/runner.py a rewrite of the chunked
overlap-add. Every departure from the official forward pass sits behind
a switch, so one experiment flips one switch.
Result. All switches off: 22.256 s, and 59–69 dB SDR against vanilla. It is the same computation, so this run is the control: it proves the port is right before anything is changed, and that the port itself bought nothing.
Concept — make the first experiment a no-op. If the rewrite had come with three optimisations already applied and failed the gate, there would be four suspects. A port that reproduces the baseline's speed and output isolates every later change.
step 6 — fused rotary embedding (exp002): 22.26 s -> 17.19 s
What. The cos and sin tables depend only on the sequence length, so they are built once and cached, with the sign of the pair swap baked into the sin table. The rotation becomes three kernels (swap pairs, multiply, multiply-add) instead of nine.
A detail that matters for fidelity. Vanilla casts
the angles to fp16 before taking cos and sin. Position 1 300
times a frequency is not representable in fp16 beyond integer precision,
so vanilla's rotations are computed from rounded angles — and the model
was trained that way (use_amp: true). Computing the
"correct" fp32 cos would be more accurate and less faithful. The cached
tables reproduce vanilla's rounding.
Result. 1.29x. Gate: pass (worst clip 60.6 dB vs vanilla).
step 7 — one-pass RMSNorm with folded gains (exp003): 17.19 s -> 15.50 s
What. F.normalize(x) * scale * gamma
becomes x * rsqrt(sum(x^2)), and scale * gamma
— constants at inference — is multiplied into the columns of the linear
layer that follows each pre-norm, once, at load time:
W (g * x_unit) = (W * g) x_unit
It also keeps the tensor in fp16. Vanilla's fp32 gamma
promoted every norm output to fp32, which doubled the bytes every
following kernel had to move.
Result. 1.43x cumulative. Gate: pass.
Concept — constant folding. Anything that is fixed after training and commutes with the next linear map can be absorbed into that map's weights. It costs nothing at run time and removes a full pass over the activations.
step 8 — flash attention: measured, not assumed (exp004, rejected; exp007, kept)
The official code disables the flash kernel off-A100, and the obvious move was to turn it back on. A first micro-benchmark said flash was slower on the 3050 and the idea was dropped. That benchmark passed the same tensor as q, k and v. With three distinct tensors, as in the model:
| axis | shape (sequences x length) | memory-efficient | flash | cuDNN |
|---|---|---|---|---|
| time | 60 x 1301 | 18.9 ms | 16.0 ms | 16.6 ms |
| band | 1301 x 60 | 2.6 ms | 3.8 ms | 4.0 ms |
Neither kernel wins everywhere: flash is better on long sequences,
the memory-efficient kernel on many short ones. The runtime picks per
axis (attn=tuned): 10.46 s -> 10.15 s. Gate: pass.
Concept — benchmark the real shapes, with realistic inputs. The first benchmark was wrong in an innocent-looking way, and "flash is faster" would have been wrong too. The answer depended on sequence length, and only a per-axis measurement showed it.
step 9 — torch.compile (exp005): 15.50 s -> 11.54 s
What. torch.compile traces the model
and fuses chains of elementwise ops into generated kernels —
automatically what steps 6 and 7 did by hand, applied everywhere.
Result. 1.92x. Gate: pass, but with the smallest margin so far (worst clip 39.5 dB against a 36 dB limit), because the generated kernels round differently.
Why it is not the final answer. The compiler fuses what is adjacent in the graph. It cannot change the data layout, cannot merge a residual add with the two norms that follow it across module boundaries, and cannot remove the copies the attention call makes. It also costs about a minute of compilation on first use. The next step does the same job by hand and goes further.
step 10 — hand-written kernels for everything that is not a GEMM (exp006): 10.46 s
What. runtime/kernels.py: five kernels
written in Triton (a Python-embedded language for GPU kernels; it ships
with PyTorch, so nothing is built or installed). Each replaces a chain
of PyTorch ops with one pass:
| kernel | replaces | ms | bandwidth |
|---|---|---|---|
rotate_qkv |
split q/k/v, rotary on q and k, layout copy for attention | 3.9–4.5 | 106–123 GB/s |
gate_merge |
sigmoid gate, multiply, merge heads, layout copy back | 1.3 | 120 GB/s |
add_rmsnorm |
residual add (+ bias) + output norm + the next pre-norm | 1.3 | 123 GB/s |
bias_gelu_ |
bias add + GELU, in place | 2.6 | 123 GB/s |
rmsnorm_unit |
a lone pre-norm | 0.65 | 123 GB/s |
The trunk (MelBandRoformer.trunk_kernels) is then:
cuBLAS GEMMs, the attention kernel, and these. One block axis is eleven
kernel launches.
Three ideas inside it.
- One pass is the floor. Every kernel lands at ~123 GB/s, this card's memory bandwidth. A kernel at the bandwidth limit cannot be made faster; it can only be removed.
- Never transpose the tokens. The official code reshapes the tensor between the time axis and the band axis, a 40 MB copy each way. But norms, linear layers, GELU and residual adds act on each token independently and do not care about order. Only attention does. So tokens keep one fixed order for the whole trunk, and the two attention-facing kernels take strides that say which tokens form a sequence.
- Fuse across module boundaries. A block ends with
x = ff(x) + x, then the output norm, and the next block starts with its own pre-norm. That is one kernel that reads two tensors and writes two (add_rmsnorm): the residual stream and its unit-normalised copy. Biases ride along: no GEMM carries a bias, because adding it would be another pass.
Result. 2.12x. But first:
The gate caught a bug that SDR did not
The first run of this step failed: waveform SDR was a healthy 39–41 dB, yet log-mel distance was 1.7–2.0 dB (limit 0.3) and the MERT frame check dropped to 0.85 (limit 0.985).
The cause was one line. Converting the model to fp16 with
.half() also converted the STFT window, a 2 048-sample Hann
window stored as a buffer. Rounded to fp16 it is wrong by about -75 dB —
negligible, except that the band split normalises every band to unit
length. In a band that is nearly silent, the content is numerical
noise at the 1e-6 level, and normalisation scales that noise up to full
size. A -75 dB change to the window changed the band-split features by 8
dB SNR. The final waveform barely moved (quiet bands carry almost no
energy, so SDR does not see them), but the quiet parts of the spectrum
did.
Fix: keep the window in exact fp32. Fidelity went from 41 dB to 65 dB.
Concept — why the gate has more than one metric. SDR is an energy-weighted average, so it is blind to errors in quiet regions. Log-mel distance and a music model's embeddings weight quiet and loud content more evenly. This bug would have shipped under an SDR-only gate. It also shows the model is chaotic in silence: any runtime change perturbs near-silent bands, which is why the strict thresholds are set from measured yardsticks rather than at "bit-exact".
step 11 — skip the chunk nobody uses (exp008): 10.15 s -> 9.31 s
What. The official chunker reflect-pads half a chunk at each end, runs every chunk, and crops the padding off the result. For a 60 s clip the last of the 12 chunks lies entirely inside the trailing padding: every sample it produces is cropped. Skipping it changes nothing in the output and removes 1/12 of the work.
Result. 2.38x, bit-for-bit the same audio as exp007. Gate: pass.
Concept — look for work whose result is discarded. No amount of kernel tuning finds this; it comes from reading what the outer loop does with its outputs.
step 12 — fp16-accumulating GEMMs (exp009): rejected
What. cuBLAS can accumulate fp16 matrix products in
fp16 instead of fp32
(torch.backends.cuda.matmul.allow_fp16_accumulation), using
faster tensor-core kernels: the 1024 -> 256 GEMM drops from 2.8 to
2.0 ms.
Result. 9.03 s, and the gate failed: one dev clip's MERT frame check came in at 0.969 against 0.985. The change is reverted. The threshold is not loosened to admit it: 3 % of speed is not worth a measurable drift, and a limit that moves whenever an experiment misses it is not a limit.
step 13 — reduced overlap under the open profile (exp010–exp012, exp014–exp015)
What. Test whether reducing the overlap between chunks helps speed on the open profile. exp010–exp012 cut overlap from vanilla's 50 % to 37.5 %, 25 % and 12.5 % (hop 0.625, 0.75, 0.875). exp014–exp015 repeat the two lower overlaps with wider crossfades.
Result.
| experiment | hop | overlap % | fade len | latency (s) | speedup | dev sdr (dB) | worst 1s (dB) | passed |
|---|---|---|---|---|---|---|---|---|
| exp010 | 0.625 | 37.5 | 10% | 6.764 | 3.28x | 19.4 | 1.79 | FAIL |
| exp011 | 0.75 | 25 | 10% | 5.933 | 3.74x | 21.3 | -0.93 | FAIL |
| exp012 | 0.875 | 12.5 | 10% | 5.086 | 4.36x | 18.6 | 0.54 | FAIL |
Ground truth SDR: vanilla dev 13.74 dB. exp010 13.68 dB, exp011 13.66 dB, exp012 13.66 dB.
Thresholds: the open profile requires SDR >= 28 dB, worst 1s >= 5 dB and a truth drop <= 0.05 dB. All three fail on multiple counts, so the failure is not a borderline miss that a looser threshold would rescue.
Verdicts. Each experiment fails both the dev eval
and the truth quality check, and none is kept. What reduced overlap
loses is not seam smoothness but the ensembling effect — at 50 % overlap
every sample is covered by two predictions, which the overlap-add then
averages — plus the chunk-edge context each prediction gives up.
Thresholds were not loosened to admit any of them. Stems were pulled to
listen/ for listening checks; the final call is left to the
user.
A note on exp014 and exp015. They were launched
without the fast-path options (no rotary=fused,
norm=fold, kernels=true,
attn=tuned, skip_dead=true), so their
latencies — 12.995 s and 16.720 s — are not comparable to exp010–exp012
and must not be presented as speed results. Only their quality verdict
is meaningful, and it is the same failure pattern as exp010–exp012:
wider crossfades do not bring the lost quality back.
Concept — overlap changes prediction context and averaging. Overlap averages predictions made with different chunk boundaries and gives samples different edge context. The ground-truth losses here are measurable but small. Wider crossfades do not restore the original scores, which is consistent with a context/averaging effect, but does not isolate those mechanisms or establish how audible the loss is. The original gate rejects the runs; the later policy review below makes the speed/quality preference explicit.
step 14 — split q/k and v GEMMs (exp013): 9.314 s -> 9.185 s
What. Rewrite to_qkv into a separate
q/k GEMM and a v GEMM. On the band axis the v GEMM's output is already
in the (sequence, position, head, e) order that attention
needs, so v becomes a view instead of a copied tensor. The
rotary kernel then reads and writes only q and k. On the time axis v
still has to be re-laid-out, so the saving applies to one axis only.
Result. 9.314 s -> 9.185 s, a 1.4 % gain, above the 0.5 % noise rule. The output is numerically identical to exp008. Gate: pass strict.
Concept — layout is a cost you either pay or avoid. A tensor's values and its order in memory are separate things, and only some consumers care about the order. A kernel that must reorder a tensor pays a full read and a full write of it; a consumer that can express the order it needs as a stride pays nothing. Here the v GEMM happens to emit its result already in the order attention wants on the band axis, so the copy vanishes; the same output on the time axis is laid out wrongly and the copy stays. The gain is small because one copy among many is small, but the technique generalises: before optimising a copy, ask which consumer actually needs the new order and whether it can be handed a stride instead.
step 15 — things measured and not pursued
- Batching the 60 per-band mask-head MLPs. The per-band Python loop costs 25.9 ms; batching the bands into one call costs 27.2–27.7 ms (two hidden layers, one stem). Batching is slower here, and the head is dominated by the 1024 x 1024 hidden GEMM, which is compute-bound whether the bands are batched or not. Not pursued.
- A "read each element once" rotary kernel. A variant
that reads every element a single time measured 5.3 ms against 4.5 ms
for the kernel that was kept — strided loads cost more than the extra
pass they save. Reverted, and noted in the
runtime/kernels.pydocstring.
Experiment results
| name | profile | median (s) | speedup | passed | note |
|---|---|---|---|---|---|
| exp000-vanilla | strict | 22.187 | 1.00 | yes | vanilla through the full gate: pipeline self-test |
| exp001-port | strict | 22.256 | 1.00 | yes | lean port, all switches off: must equal vanilla |
| exp002-rotary | strict | 17.193 | 1.29 | yes | cached tables, 3-kernel rotary |
| exp003-norm | strict | 15.495 | 1.43 | yes | rsqrt norm, gains folded into next linear |
| exp005-compile | strict | 11.539 | 1.92 | yes | torch.compile default mode |
| exp006-kernels | strict | 10.496 | 2.11 | no | fused trunk: 4 triton kernels, fixed token layout, fp16 weights |
| exp006b-kernels | strict | 10.462 | 2.12 | yes | fused trunk: 4 triton kernels, fixed token layout, fp16 weights |
| exp007-attn | strict | 10.148 | 2.19 | yes | flash kernel on the time axis only |
| exp008-skipdead | strict | 9.314 | 2.38 | yes | skip the chunk that lies wholly in the cropped border |
| exp009-fp16acc | strict | 9.032 | 2.46 | no | cublas fp16 accumulation |
| exp010-hop0625 | open | 6.764 | 3.28 | no | open: 37.5 pct overlap |
| exp011-hop075 | open | 5.933 | 3.74 | no | open: 25 pct overlap |
| exp012-hop0875 | open | 5.086 | 4.36 | no | open: 12.5 pct overlap |
| exp013-splitv | strict | 9.185 | 2.42 | yes | split q/k and v GEMMs: v is a view on the band axis |
| exp014 | open | 12.995 | 1.71 | no | hop=0.75, fade=0.25; no fast-path options, latency not comparable |
| exp015 | open | 16.720 | 1.33 | no | hop=0.625, fade=0.375; no fast-path options, latency not comparable |
Where it stands
Sixteen gate runs, in the table above. Latency is the median of five
timed runs on bad_guy_60s; speedup is against the frozen
vanilla baseline of 22.194 s.
Headline. Same settings as vanilla (strict profile): 9.185 s, 2.42x (exp013-splitv). The reduced-overlap runs on the open profile are faster — 5.086 s to 6.764 s — but all of them fail the gate, so they are not achieved results and are not the headline. exp014 and exp015 were run without the fast-path options and their latencies are not comparable to anything else in the table.
A historical note. The archived earlier attempt
("golf", tag golf-v1-archive) reported 4.43 s, but with 8 s
chunks at 5 % overlap, i.e. about 72 s of audio pushed through the model
for a 60 s clip, versus about 143 s at vanilla settings (13 s chunks, 50
% overlap); per second of audio processed the two runtimes are about
equal (about 61 vs 65 ms per second), and that attempt's quality was
never checked against vanilla or ground truth.
What is left. Trunk GEMMs and attention now account for 60.2 % of each chunk. The profile below shows the remaining work and the effect of the changes.
After: profile of the optimised runtime
The final profile uses the exp013 options: fused rotary, folded norms, fp16 weights with the STFT window retained in fp32, the Triton trunk, attention selected per axis, dead-chunk skipping, and split q/k and v GEMMs. Chunk length and overlap remain 13 s and 50 %. The strict gate passes; ground-truth SDR remains 13.744 dB on dev and 15.001 dB on test at the reported precision.
The table comes from CUDA-event stage timing without the trace profiler attached. Each row covers one 13 s chunk; linear layers here are the trunk GEMMs, with band-split and mask-head work counted separately.
| stage | vanilla ms | optimised ms | ratio | share of optimised |
|---|---|---|---|---|
| rotary embedding | 656.6 | 87.2 | 7.5x | 10.6 % |
| norms and residual adds | 401.1 | 62.9 | 6.4x | 7.6 % |
| attention kernel | 230.7 | 220.1 | 1.0x | 26.7 % |
| linear layers (GEMM) | 352.7 | 276.2 | 1.3x | 33.5 % |
| GELU | 62.7 | 62.3 | 1.0x | 7.5 % |
| gate multiply, head reshapes | 32.6 | 31.9 | 1.0x | 3.9 % |
| mask heads | 57.2 | 61.0 | 0.9x | 7.4 % |
| everything else | 53.1 | 23.7 | 2.2x | 2.9 % |
| one chunk | 1846.8 | 825.3 | 2.24x | 100 % |
| chunks for 60 s | 12 | 11 | ||
| wall time in these stage-profile runs | 22.27 s | 9.11 s | 2.44x |
Rotary and norms/adds together fall from 1057.7 ms to 150.1 ms per chunk: about 908 ms saved out of the total 1022 ms reduction. The remaining whole-request gain comes from skipping the twelfth chunk, whose output is entirely cropped. The stage-profile wall times are separate observations; the headline remains the gate's five-run median of 9.185 s versus 22.194 s, or 2.42x.
What the new timeline shows
The trace captures a shorter request with five chunks, rather than the 60 s headline run. Tracing inflates a chunk to roughly 962 ms, compared with 825 ms in the CUDA-event measurement. Use the screenshots to inspect order and kernel structure, and the table above for time comparisons.

Five chunks back to back in the trace. The GPU remains busy through the trunk; there is little idle time for launch-overhead changes to recover.

One chunk from STFT through mask application and iSTFT. The transformer trunk still dominates.

STFT, mel-band gather and band split lead into the first transformer. These front-end operations remain a small part of the request.

The first block runs time-axis attention and feed-forward, then band-axis attention and feed-forward. Tokens retain one memory order through the trunk; the attention-facing kernels handle the axis-specific layout.

Time-axis attention: fused rotation/layout, flash attention, gate merge, output GEMM and residual/norm fusion. The long flash-attention kernel is now much more prominent because the surrounding memory passes have shrunk.

Band-axis attention uses the memory-efficient kernel for 1301 short sequences of 60 bands. The split v GEMM already emits the required band-axis layout, so v is a view; the rotary kernel processes q and k only. Both axes use the same Triton kernel name, with different compile-time arguments.

This crop starts at the tail of the up GEMM, then shows fused bias/GELU, the down GEMM and the fused residual/norm operation. GELU's absolute time barely changes; its share rises because other work became faster.

A 1.5 ms excerpt inside the fused time-axis rotary kernel. The nine-pass chains for q and k have become a single launch that also lays out v. This is an excerpt, not the full kernel duration.

Residual add, output norm and the next pre-norm share one kernel. It writes both the residual stream and its unit-normalised copy for the next linear layer.

The mask estimators still run 60 per-band MLPs for each of two stems. They take 61.0 ms per chunk in the stage profile, slightly more than vanilla's 57.2 ms; the improvement comes from the trunk. Batching the heads was measured and was slower on this card.

Complex masks, scatter-add averaging across overlapping bands, multiplication and iSTFT reconstruct the stems.

The transition between chunks includes GPU overlap-add and the host loop. In the stage run, all work outside the chunk spans totals about 37 ms across the 60 s request.
What remains
Trunk GEMMs take 33.5 %, attention 26.7 %, rotary 10.6 %, norms/adds 7.6 %, GELU 7.5 %, and mask heads 7.4 % of a chunk. The custom kernels measured roughly 106–123 GB/s, near the observed bandwidth ceiling for these operations. More gains would require removing traffic or work, or improving the matrix and attention kernels; repeating the same fusion strategy has less room left.
The kernel trace supports the same shift: GEMMs are 40.1 % of device time when the front-end and mask-head GEMMs are included, flash attention 24.1 %, and band-axis attention 3.5 %. Its generic "other" category includes the custom Triton kernels, so that category must not be read as unexplained overhead. These trace percentages use a different denominator from the untraced stage table.
The result is specific to this checkpoint, card, clip and frozen gate: 2.42x at the original chunking settings, with the strict quality checks passing. Reduced overlap remains a rejected experiment under the existing thresholds.
Reduced overlap revisited: an explicit speed/quality tradeoff
The original open profile remains frozen. Its failures are correctly recorded, but those limits were chosen to keep the output close to vanilla, not to measure the audibility of every difference. On 2026-10-01 the user requested a practical tradeoff. A separate, versioned tradeoff-v1 policy now permits 0.10 dB mean ground-truth SDR loss on each split, keeps the original pooled-MERT floor of 0.995, allows log-mel distance up to 2.0 dB and level drift up to 0.05 dB, and retains finite/exact-length and VRAM checks. Waveform SDR, worst-window SDR and frame MERT remain diagnostics. These are engineering budgets chosen after inspecting the runs; they are not validated audibility thresholds.
| overlap | measured latency | vs vanilla | vs strict runtime | truth SDR loss, dev | truth SDR loss, test | tradeoff-v1 replay |
|---|---|---|---|---|---|---|
| 50 % (exp013) | 9.185 s | 2.42x | 1.00x | 0.0001 dB | -0.0000 dB | pass |
| 37.5 % (exp010) | 6.764 s | 3.28x | 1.36x | 0.0594 dB | 0.0099 dB | pass |
| 25 % (exp011) | 5.933 s | 3.74x | 1.55x | 0.0816 dB | 0.0420 dB | pass |
| 12.5 % (exp012) | 5.086 s | 4.36x | 1.81x | 0.0863 dB | 0.0642 dB | fail |
Practical choice: 25 % overlap. It takes 35.4 % less time than the strict runtime. Its average SDR loss corresponds to about 1.9 % more geometric-mean error energy on dev and 1.0 % on test, relative to vanilla's existing separation error. This is a small measured aggregate degradation. It does not prove that every artifact is inaudible. Among the four inspected dev tracks, the largest per-stem loss is 0.1224 dB; some other overlap settings improve individual stems.
The 19–21 dB fidelity scores measure distance from vanilla's estimates, while the roughly 13.7/15.0 dB truth scores measure actual separation quality. Those are different references. A change in chunk context can move the estimate considerably without increasing its error against the ground truth by much. At 25 % overlap, the headline vocals' fidelity is 34.19 dB, compared with 21.32 dB for the worst dev stem. Listening only to the headline is therefore incomplete.
The worst dev one-second fidelity at 25 % overlap (-0.93 dB) is in the silence edge case; the worst frame-MERT result is also on that clip. A quiet reference makes relative-error metrics sensitive to small absolute changes. This finding limits what can be inferred from the summary; it does not dismiss the need to listen to quiet passages.
Why not 12.5 % yet? It has a distinct short-clip regression: edge_short vocals show 5.11 dB log-mel drift, frame MERT 0.411 and pooled MERT 0.889. The new policy rejects it on global embedding and spectral checks despite its small average truth-SDR loss. The no-overlap yardstick also still fails. Wider fades are not a demonstrated repair.
Replay results are recorded in the
replay results. Existing experiment verdicts and
gate/baseline.json are unchanged; no GPU rerun was
performed, and the old test set has already informed this policy review.
A fresh dataset or blinded listening would provide independent
validation. The exp011 timing predates split-v, so no additional split-v
speed gain is claimed for the 5.933 s result.
To reproduce exp011 under the new policy, use the same options it was measured with:
uv run python gate/gate.py --runtime runtime --name <new-run-name> --profile tradeoff \
--opt rotary=fused --opt norm=fold --opt half=true --opt kernels=true \
--opt attn=tuned --opt skip_dead=true --opt hop=0.75