lab

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.

Latency falls from 22.19 seconds for vanilla to 9.19 seconds at the original settings, or 5.93 seconds with 25 percent overlap.
Selected runs: 50% overlap except exp011 (25%, measured before split-v).

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:

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 %

overview

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

chunk

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

time attention

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

time rotary

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 %

sdpa

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 %

feed-forward

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

Frequency axis — continues the pattern

freq attention

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

freq sdpa

Freq axis: scaled_dot_product_attention, gates, to_out.

freq feed-forward

Freq-axis feed-forward and output norm.

Band split and mask estimation

band split

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

mask head

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

Mask apply and ISTFT

mask apply istft

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

Between chunks

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.

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:

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.

  1. 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.
  2. 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.
  3. 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

  1. 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.
  2. 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.py docstring.

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.

Optimised request, all tracks

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 optimised chunk

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

Start of an optimised chunk

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

First optimised transformer block

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.

Optimised time-axis attention

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.

Optimised band-axis attention

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.

Optimised time-axis feed-forward

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.

Fused rotary kernel detail

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.

Fused residual and norm detail

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.

Optimised mask estimators

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.

Mask application and reconstruction

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

Transition between optimised chunks

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
Mean ground-truth SDR losses on dev and test for 50, 37.5, 25 and 12.5 percent overlap; all are below the 0.10 dB tradeoff budget.
Dashed: 0.10 dB tradeoff budget. Hollow points mark the rejected 12.5% setting.
Worst dev pooled MERT and log-mel drift: 12.5 percent overlap misses both tradeoff limits, while 37.5 and 25 percent pass.
Pooled MERT and log-mel use vanilla as reference. Limits belong to the post-hoc tradeoff policy.

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