← overview

LeVo 2 throughput

The slowest model in the benchmark was running at batch 1 because its inference code asserts batch 1, and spending 94 % of its time in a decode loop that was CPU‑dispatch bound, not GPU bound. Fixing both gives 13.6× realtime on one GH200, measured end to end.

0.67×baseline realtime factor, agg.json
13.6×best measured, 30 s clips, batch 32
8.5×best measured, full 2.7 min songs, batch 8
−68,400GPU‑hours saved per 1 M 3‑minute songs

The finding, in one paragraph

LeVo's inference code is hard‑wired to batch 1CodecLM._prepare_tokens_and_attributes opens with assert len(lyrics) == 1, and LmModel.generate computes num_samples from the input then throws it away, calling prepare_condition_tensors(batch_size=1, ...). Our driver gen_levo.py was correctly calling the only API that exists. Separately, a torch profiler run shows each decode step issues ~2,437 individual op dispatches for 9 ms of actual GPU work, while wall clock per step is 43 ms. The loop is bound by CPU dispatch, not by the GPU. Those two facts compound: because the CPU cost is per step and not per batch element, batching is very nearly free, and capturing the step in a CUDA graph removes most of what remains.

What the model actually is

Read from songgeneration_v2_large/config.yaml and the loaded checkpoint, not from the paper. LeVo 2 large is two stacked LLaMA decoders plus a flow‑matching vocoder:

componentshapeparamsrole
transformer (main)36 layers, d=2048, ffn=11008, 16 heads, no GQA3.106 Bpredicts codebook 0 (the "mixed" stream)
transformer2 (sub)12 layers, same width1.080 Btakes main hidden state + codebook 1/2 embeddings, drives two linear heads for vocal & bgm
conditioner embeddings2 × Qwen2 vocab (151,652 × 2048)~0.94 Blyrics + tag text
LM totalfp165.122 B~10.2 GiB of weights
Flow1dVAE vocodernon‑causal GPT‑2, 50 Euler steps, CFG 1.5separate modulecodes → 48 kHz stereo

Three things about the decode loop matter for throughput and are easy to miss:

The conditioning prefix is a fixed 952 tokens (lyrics 600 + tags 100 + audio‑prompt slot 252), padded to constant length regardless of content. That is what makes batching straightforward: every sequence in a batch has an identical‑length prefix, so no ragged padding or attention‑mask work is needed.

Where the 44 seconds go

Batch 1, 30 s target, dedicated compute node, torch.cuda.synchronize() around every phase.

phasesecondssharenote
LM token generation34.2694.0 %769 steps @ 22.45 steps/s = 44.5 ms/step
Vocoder — CFM Euler solve1.504.1 %50 steps, CFG 1.5
Vocoder — VAE decode0.691.9 %latents → 48 kHz stereo
total36.4520.72 s audio → xrt 0.568 (model EOS'd early)
model load (once)145.5LM 126.8 s + vocoder 18.7 s; amortised over a run

Peak VRAM at batch 1: 16.78 GiB of 95. Roughly 78 GiB per GPU was sitting idle.

Why the LM step costs 44 ms

measurementvaluewhat it says
wall clock per decode step43.5 ms
GPU time per step (profiler)9.0 msthe GPU is idle ~79 % of the step
CPU time per step (profiler)58.4 msdispatch, not compute, is the critical path
op dispatches per step~2,437~24 µs of CPU per op
  of which aten::mm340/step, 41 % of GPU timethe real work
  of which aten::cat195/step, 21 % of GPU timethe KV cache being reallocated and copied every step
per‑layer: attention0.656 mseager matmul→softmax(fp32)→matmul
per‑layer: MLP0.108 ms4× the FLOPs of attention, 6× less time
The MLP is where the FLOPs are and it takes a sixth of the time attention does. That is the signature of a latency‑bound loop. Two structural causes: attention runs the eager path (flash_attn has no aarch64 wheel, so the vendored LlamaAttention falls back to explicit matmul + fp32 softmax), and the KV cache grows by torch.cat on every layer on every step, reallocating and recopying the whole cache 96 times per token.

Batch‑size sweep

Stock kernels, batching patched in, vocoder run as an independent stage at batch 4. Dedicated compute node, one GH200. PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True.

batchwall (s)audio (s)× realtimes / songLM (s)LM steps/sCFM (s)VAE (s)peak VRAM
136.520.70.5736.534.322.451.50.716.8 GiB
237.641.81.1118.834.323.852.40.919.4 GiB
445.990.61.9711.539.324.554.81.724.8 GiB
855.1186.03.386.941.923.879.63.535.0 GiB
1683.3375.84.515.256.017.8420.17.054.8 GiB
24117.2585.85.004.976.313.1130.210.475.2 GiB
32CUDA OOM — 95 GiB exhausted by the torch.cat KV cache

Batch 1→8 is almost perfectly linear: LM steps/s is flat (22.45→23.87 — it actually goes up), so 8× the batch is 5.9× the throughput, with the shortfall entirely in the vocoder, which does not benefit from batching (it is already compute‑saturated; CFM time scales linearly with batch). Beyond 8 the LM starts to slow as it becomes genuinely GPU‑bound, and beyond 24 the stock cache runs out of memory.

Two correctness bugs surface only at batch > 1, both fixed in the harness:
1. Batch‑wide truncation. CodecLM.generate does length = torch.nonzero(tokens == eos)[:, -1].min() — the minimum EOS position across the whole batch — then truncates everything to it. Correct at batch 1; at batch 16 it silently cuts every song down to the length of the shortest one. We keep per‑row lengths and trim each song's audio individually.
2. Batch‑1 assumptions in sampling. The repetition‑penalty pool is built with next_token.squeeze(), which collapses the batch dimension, and the token ban is applied as logits[0][0][...] — row 0 only. Both were rewritten to be per‑row.
Outputs are not reproducible across batch sizes. With an identical seed and identical lyrics, row 0's generated length was 20.72 s at batch 1, 19.16 s at batch 2, 17.24 s at batch 4 and 16.28 s at batch 16. Sampling consumes one shared RNG stream in a different order as batch changes, and batched GEMM reductions associate differently. A seed reproduces a song only at a fixed batch size. Plan around this if any provenance or regeneration guarantee is needed.

Optimisations, measured one at a time

Each row changes exactly one thing relative to the row above it, so the gains are attributable.

changebatchLM ms/stepgain on LMend‑to‑end ×RTVRAMwhere measured
stock, batch 1144.50.5716.8node
+ batching (the whole answer)841.96.3× aggregate3.3835.0node
+ torch SDPA instead of eager attention835.41.18×3.9934.7node
+ CFM 50 → 10 Euler steps8unchangedvocoder 9.6→2.0 s4.2835.3node
+ static KV cache (no cat)835.6~flat, but unlocks graphs & batch 32+23.2login
+ CUDA graph on the decode step813.92.56×7.0432.6login
+ batch 16, CFM 101621.711.7250.7login
+ batch 32, CFM 103234.013.5686.8login

Static KV cache + CUDA graphs — the second lever

Stock LeVo cannot be graphed at all: LlamaAttention.forward grows the cache with torch.cat, so every tensor shape changes every step. Replacing it with a preallocated buffer written by index_copy_ at a position index makes every shape static, at which point the whole 48‑layer step captures into a single torch.cuda.CUDAGraph. LM decode only:

batchstatic cache, eager (ms/step)CUDA graph (ms/step)graph speedupLM‑only ×realtimepeak VRAM
835.613.92.56×23.123.2 GiB
1636.019.51.85×32.936.6 GiB
3237.630.91.22×41.563.5 GiB
4846.142.01.10×45.790.3 GiB

The graph speedup shrinks as batch grows — 2.56× at batch 8 down to 1.10× at batch 48 — which is exactly what the profile predicts. CUDA graphs remove CPU dispatch overhead; once batch is large enough that the GPU work exceeds that overhead, there is nothing left to hide. Batching and CUDA graphs attack the same bottleneck, so they do not multiply. Anyone stacking both should expect the combined figure, not the product.

The fastest configuration that actually works

Recommended: batch 16, static KV cache, CUDA‑graphed decode step, torch SDPA, vocoder as a separate stage at batch 8 with 10 CFM steps.
Measured 11.7× realtime at 50.7 GiB for 30 s clips — leaving comfortable headroom on a 95 GiB card. Batch 32 reaches 13.6× but at 86.8 GiB, which is too close to OOM to run unattended across thousands of jobs. All outputs verified as real audio (see below).
configbatchwall (s)audio (s)×RTs/songLM (s)vocoder (s)VRAM
baseline (agg.json, production)144.429.80.67344.4~42~2.316.8
graph + batch, 30 s clips829.0204.07.043.615.813.232.6
graph + batch + CFM 10, 30 s clips1633.4391.311.722.121.711.750.7
same, pushed3259.8810.513.561.934.025.886.8
full songs (2.7 min each), CFM 108152.91302.58.5219.1120.432.554.9

Cost for 1 million 3-minute songs

The full‑song row is the one to cost against. It is the only measurement made on genuinely multi‑minute output. Song length is driven by the lyrics, not by --duration — asking for 180 s with a single [verse] still produces ~27 s, because the model emits EOS when the lyrics are done. Full structured lyrics ([intro-long] ; [verse] ; [chorus] ; [inst-long] ; [verse] ; [chorus] ; [outro-long]) produced 163 s per song, which is what the 8.52× figure is measured on. The 3‑minute numbers below scale that by 180/163 — that scaling is an extrapolation; everything else in this table is measured.
configuration×realtimeGPU‑s per 3‑min songGPU‑hours per 1 Mnode‑hours (4 GPU)core‑hours billed
baseline, batch 10.673267.574,30018,5805.35 M
batching only, batch 24, stock kernels5.0036.010,0002,500720 k
recommended (graph + batch + CFM 10)8.5221.15,8701,470423 k

Saving versus the current pipeline: about 68,400 GPU-hours, or 4.9 M billed core-hours, per million songs — a 12.7× reduction. For reference, ACE‑Step XL turbo currently sits at 2.82× realtime; the recommended LeVo config is 3.0× faster than that, so LeVo would go from being the pipeline's bottleneck to being its fastest arm.

Output validation

A fast wrong answer is worthless, so every batched configuration was checked for real audio rather than silence or truncation. Frames above −40 dBFS, on 50 ms windows:

filechannelsdurationRMSpeakactive frames
batch 16, CUDA graph230.0 s0.1361.2279.5 %
batch 16, CUDA graph230.0 s0.1380.9265.3 %
batch 32, CUDA graph230.0 s0.0731.1674.8 %
batch 32, CUDA graph230.0 s0.1370.9861.5 %
production batch‑1 reference230.0 s0.2371.2192.5 %
production batch‑1 reference230.0 s0.1761.0383.5 %

Batched outputs are unambiguously real vocal audio at full length. They are somewhat quieter and sparser than the production reference, which is consistent with the different (shorter, single‑verse) test lyrics used here rather than with a batching artefact — but this has not been confirmed by a listening test or by the judge rubric, and should be before a large run. The RMS spread within a single batch (0.07–0.14 at batch 32) is normal variation across prompts.

What did not work, and why

Ranked next steps, not implemented

#optimisationexpected gaineffortwhy
1Bucket the static KV buffer to the actual sequence length1.15–1.3× on long songslowOur decode attends over the full maxlen buffer, not the filled prefix. At maxlen 5800 the step cost rose 21.7→25.3 ms for the same batch. Round the buffer up to the actual song length instead of a global maximum.
2Run the vocoder on a different GPU from the LM1.3–1.5× end‑to‑endmediumAt batch 32 the vocoder is 43 % of wall clock and runs strictly after the LM. The two stages have opposite profiles (LM latency‑bound, vocoder compute‑bound), so pipelining them across 2 of the 4 GPUs should overlap almost completely. This is exactly the pattern the TTS serving stacks converged on.
3Drop transformer2's unused lm_head~2 % trivialA 2048×16385 matmul plus an fp32 cast is computed every step and the result is discarded — only hidden_states is used. One line.
4Graph the sampling tail as well5–10 %lowSoftmax, top‑k and the repetition‑penalty scatter still run eagerly outside the graph, ~100 dispatches/step.
5Continuous batching / rolling admission1.2–1.4×highA fixed batch runs until the last song emits EOS. Measured spread within one batch was 16–30 s, so ~25 % of decode slots are wasted on finished rows. Admitting a new song when one finishes recovers that.
6Generate longer songs per call1.2× on 30 s workloadstrivialThe 250‑step delay is a flat cost: 25 % overhead on a 30 s clip, 5 % on a 3‑minute one. The vocoder also pads anything shorter than 40 s up to a 40 s window. If the product allows longer outputs, they are strictly cheaper per second.
7FP8 / W8A8 on the LM1.1–1.4× at large batchhighThe one quantisation family with evidence of helping in the compute‑bound regime (~40 % at batch 256 vs ~10 % for W4A16). Needs Transformer Engine and careful audio‑quality validation.
8Reduce CFG to 1.0up to 2×trivial — but changes outputCFG doubles the model batch for free quality. Turning it off halves LM compute outright. This is a quality decision, not an engineering one, and would need a judge‑rubric A/B before adoption.
One warning worth carrying into any large run. Two independent audio‑serving projects report catastrophic output‑quality outliers specifically at large CUDA‑graph batch sizes (SGLang‑Omni measured 99–100 % word error at graph batch ≥16 against a 0.1–0.4 % baseline, worse with torch.compile also enabled). Our own outputs look correct by RMS and activity, but that is a coarse check. Before committing thousands of GPU‑hours, run the existing judge rubric on a few hundred batch‑16 and batch‑32 clips and compare against the batch‑1 scores.

Sources

Measured here

LeVo / SongGeneration

Technique and aarch64 availability

All timings on NVIDIA GH200 (95 GiB), aarch64, torch 2.7.1+cu128, transformers 4.37.2, songgeneration_v2_large, gen_type=vocal, temperature 0.9, top‑k 50, CFG 1.5. Rows marked "node" were measured on a dedicated compute node; rows marked "login" on the login‑node GH200 and are conservative.