Repository navigation
Qwen3-TTS: Run the 1.7B Depth Loop as One Persistent Kernel on Hopper - #1183
Conversation
This comment was marked as off-topic.
This comment was marked as off-topic.
``sample`` writes them when a step asks, and a kernel that draws in place doesn't.
| max_rows = MAX_ROWS | ||
|
|
||
| def __init__(self, code_predictor, talker_table: torch.Tensor): | ||
| cp, fused = code_predictor, code_predictor.fused |
There was a problem hiding this comment.
Nit / readability: this __init__ is ~70 lines with few spaces and comments. Would add line breaks and one-liner comments between logical sections of this init function, or break it up into sub functions.
There was a problem hiding this comment.
Split into _stack_weights, _allocate_scratch and _compile_sampled in 1a5007f
| if ( | ||
| self.offset_buf.device.type != "cuda" or self.min_p_buf is not None or self.history is not None | ||
| or self.order is not None or self.ingraph_scatter | ||
| or (self.tp_group is not None and self.tp_group.world_size > 1) | ||
| ): |
There was a problem hiding this comment.
This doesn't refuse a sampler with logprobs on. The description says fused_draw_args() returns None for logprobs (and that the tests check it), but nothing here reads logprob_buf, and test_sampler_fused_draw.py has no logprobs case.
With enable_logprobs on, sample writes logprob_buf, and step_host_rows hands that row back. A kernel taking this path never writes it, so step_host_rows returns whatever the row last held instead of the resource refusing. Qwen3-TTS's code_predictor spec doesn't enable logprobs, so nothing hits this today, but this is the engine API other kernels will use. Adding self.logprob_buf is not None to this condition and a test would make it match the description.
There was a problem hiding this comment.
Thanks, the description was ahead of the branch. af36689 adds self.logprob_buf is not None to the condition.
| while the CTA waits on earlier phases. | ||
|
|
||
| Task types: projections (`linear.lin_task`), attention for one q head and 16 | ||
| rows (`attend.attend4`), and the per-row draw (`fused_draw.draw_row`) and |
There was a problem hiding this comment.
Comment is out of date: an attention task covers 8 rows, not 16. schedule.py says "one q head, 8 rows", and the ty == 4 branch below runs attend4 twice at 4 rows each, from (tile % 8) * 8.
| T_QKV, T_O, T_GATEUP, T_DOWN, T_ATTEND, T_INPROJ, T_HEAD, T_EMBED = range(8) | ||
| ROW = 8 # packed task row, see `schedule.pack` |
There was a problem hiding this comment.
Nit: nothing in this file uses these. The task loop and _issue dispatch on literals (ty == 0, ty == 4, ...) and base hard-codes MAXT * 8, while schedule.py defines the same names again. Using the names in the dispatch, imported from schedule, would make the loop easier to read and keep one definition of the encoding. If Gluon needs the literals, I'd delete these and say so in a comment.
There was a problem hiding this comment.
The dispatch in _issue and the task loop now uses schedule's names, and base uses ROW, so the encoding is defined once (1a5007f). Gluon reads a module global only when it's a constexpr, so kernel.py wraps schedule's ints in gl.constexpr.
| ``depth_kernel: true`` in ``model_kwargs`` on a Hopper GPU with 128 or more | ||
| SMs, a step of up to 64 rows whose residual draws the graph sampler can hand | ||
| over runs the whole loop, sampling included, as one persistent kernel | ||
| (``mstar.model.qwen3_tts.components.depth_frame``). |
There was a problem hiding this comment.
This should also say the kernel needs Triton 3.7. torch 2.11 pins triton==3.6.0, so on the torch most installs get, depth_kernel: true only logs a warning and runs the loop. (to be tested live)
There's a second catch for a deployment that moves to torch 2.12 to get it: torchaudio stops at 2.11, and Qwen3TTSModel.load_audio imports torchaudio.functional. The description mentions a test failing for this reason. Does voice cloning on the 1.7B Base checkpoint then fail in the same deployment that turns the kernel on?
There was a problem hiding this comment.
Added in baf6893.
And yes: load_audio imports torchaudio.functional before it reads the clip, and torchaudio stops at 2.11, so every clone request on the Base checkpoint fails in a torch 2.12 deployment.
The docs and the description now say a cloning deployment can't turn the kernel on yet. Our pyproject requires torchaudio, so a plain install resolves torch 2.11 and the kernel stays off. Making cloning work without torchaudio is a change of its own, since eight other modules import it too, api_server/data_worker.py among them.
The kernel dispatches on `schedule`'s task types, read as constants, in place of literals and a second copy of the encoding. `DepthFrame.__init__` stacks the weights, allocates the scratch and compiles the sampled kernel in three methods.
The loop moves by up to 1.1% of the largest KV value and 0.8% of the largest logit between a step of the same rows alone and in 64 rows, where cp_linear picks other tiles. The frame is within 1.14% and 0.96% of the loop over six seeds at 1 to 64 rows, so 2% and 1.5% replace 5% and 2%.
Live test: Qwen3-TTS: Run the 1.7B Depth Loop as One Persistent Kernel on HopperWorks as an opt-in: the throughput claim holds (1.04x at c64) and WER doesn't move. The kernel costs 7% at c1, frame ratios come in lower than claimed, codes differ from the loop more than "near a tie" suggests, and 1x H200, Claims
Risks
Performance
The node is shared and its load varies. The job's pinned CPUs stayed under 5% busy in the logged windows, and arms were interleaved. Other
Client commands
|
|
@NSagan271 Thanks for the live test. In order:
|
Qwen3-TTS 1.7B can now run its CodePredictor's depth loop, sampling included, as one persistent kernel on Hopper.
Each Talker step runs a 5-layer CodePredictor over 16 depth positions to produce a frame's residual codes. On
mainthat loop is 337 projection, 80 attention, 15 sampling and 15 embedding launches per step. On an H200 at c64 it takes 2.6 ms of a 4.8 ms decode step. This PR runs the loop as one kernel. A frame is 1.19x to 1.25x faster at 64 rows in two measurements, and once the GPU is the limit, throughput rises 1.04x to 1.05x at c64. At one request at a time it's 7% slower, since a frame costs about the same at any row count.The kernel is opt-in (
depth_kernel: trueinmodel_kwargs). It's built only for the 1.7B shapes in bf16 on a Hopper GPU with 128 or more SMs, with Triton 3.7, which comes with torch 2.12. Elsewhere, startup logs why and the loop runs. A step the kernel can't take also runs the loop: one with more than 64 rows, or with sampler settings the kernel can't reproduce.Before #1182, the worker's host time set the pace at c64, so the GPU time the kernel saves didn't reach throughput. #1182 removed that host cost, and the throughput numbers below are measured on top of it.
How it works
schedule.py). A frame is 432 phases: qkv, attention, o, gate/up and down for 5 layers at each of 16 positions, plus the input projection, 15 heads, and 16 embedding steps, 15 of which draw a code first._cp_linear_kerneluses at 64 rows, with gate and up stacked in one 64-row weight tile. Attention usescp_attend's layouts, and each draw picks the tokenfused_samplewould.fused_draw_args()andadvance_offsets(). A kernel that draws in place reads the step's per-row temperature, top-k, top-p, seed and offset, and after the launch it moves the offsets as that manysamplecalls would.fused_draw_args()returns None whensamplewould do more than one plain draw: an eager step, the repetition penalty, min-p, logprobs, a kept last token, or TP. The Talker then runs the loop.CUDA_MPS_PIPE_DIRECTORYset). Its CTAs wait on each other, so all 132 have to be resident at once, and an MPS limit on this process's SMs would keep some of them from ever being scheduled.Where the depth loop's time goes
I profiled with and without the kernel, at four levels.
The ring rarely loads a weight tile ahead. One layer needs about 68 pages per CTA against the ring's 32, so each tile waits for the task before it to free its pages.
Decisions and experiments
When the saving reaches throughput
mainon an H200: the GPU idles 18% of the time at c64, because the codec takes one step per chunk width.Cost
At small steps the frame is slower than the loop
A frame takes 2.1 to 2.2 ms at any row count, while the loop's time grows with its rows. At 1 row the frame is 0.96x the loop in my timing and 0.89x in Naomi's live test, and by 16 rows it's ahead (1.03x to 1.09x). Her c1 throughput is 0.93x (20.1 against 21.5 audio-s/s). That's acceptable because the kernel is opt-in for deployments that run near capacity, where steps have tens of rows. Running the loop for small steps would remove the cost, once the crossover is measured.
Codes differ from the loop's
The frame's projections use the 64-row tiles at every row count, while
cp_linearpicks other tiles below 33 rows, and o and down add their two halves in their own order. Given the same prefix at 64 rows, 12.9% of the frame's draws differ from the loop's, and once one does, the rest of that frame's groups follow different codes. The loop's own draws differ by 5.9% and 4.7% between a step at 16 or 32 rows and one at 64, from its own tile choice. That's acceptable because the audio is as good: WER on the c64 audio is the same in both measurements (1.64 to 1.71% with the kernel, 1.67 to 1.73% without, in the live test), and runaway audio is as rare (8 against 7 in 48,000 requests each). The frame's order is fixed, so a seeded request gives the same audio every time on one compile cache.The frame holds every SM for 2.2 ms
The frame's CTAs wait on each other for the whole frame, so no kernel on another stream can run beside it. A kernel already running delays the frame until it frees its SMs. On
mainnothing runs beside the frame anyway: the worker's steps run one at a time, and no config runs a node on a side stream.It needs torch 2.12, where voice cloning doesn't work yet
Triton 3.7 comes with torch 2.12, and torch 2.11 pins Triton 3.6. torchaudio has no build for torch 2.12, and
Qwen3TTSModel.load_audioimports it, so on the Base checkpoint every clone request fails in a deployment that can turn the kernel on. That's acceptable because the kernel is off by default and the shipped 1.7B config serves CustomVoice, which never decodes a clip.docs/models.rstsays so.About 720 lines of Gluon and 450 of host-side scheduling, for one model's shapes
The kernel is built for the 1.7B CodePredictor (hidden 1024, 16 q and 8 kv heads of 128, intermediate 3072, 5 layers) and for Hopper's wgmma, TMA and clusters. It's acceptable because it stays inside the Qwen3-TTS package apart from the two sampler methods, and it's off by default.
What's left in the frame, open
Not built yet:
Testing
The tests check:
cp_linearpicks other tiles, and the frame is within 1.14% and 0.96% of the loop over six seeds at 1 to 64 rows.fused_sample's draw from its own logits at the last offset, and its embedding sums match a run given its drawn codes. A captured frame replays what it ran, and the Talker chooses between the frame and the loop as described above.advance_offsetsgive the tokens and offsets of fifteensamplecalls, with and without filters.fused_draw_args()returns None for an eager step, the penalty, a step that writes logprobs, a kept last token and a CPU sampler. The Gluon draw picksfused_sample's token bit for bit.The table's runs used one H200, torch 2.12.1 with Triton 3.7,
configs/qwen3tts_1p7b.yamland the Python frontend. Both arms were on #1182's branch, with the first 800 Seed-TTS English target texts and voice vivian, in closed loop. Each number is the median of 6 passes, with boots alternating:main. Its other checks are in her summary comment.depth_kernel. The kernel code is the same. On the final commit, a boot withdepth_kernel: truebuilt the frame and ran c64 at 564.0 audio-s/s.test_qwen3_tts_load_audio_rejects_a_long_clip_before_decodingfails there onmaintoo.