Repository navigation
nemotron_duplex: live calls at one-shot cost (full duplex 5/6) - #595
Conversation
conformer_step captured torch.stft (steady_step's mel) and codec_decode captured torch.fft.irfft (the codec's iSTFT). torch's cuFFT plan cache is a per-device LRU whose eviction calls cufftDestroy, and graph replays don't touch the LRU, so enough newer eager FFT geometries (eager front-end windows, uploads, eager codec windows) destroy the plans the graphs replay. Both regions now take their fixed-size transform as a float64 matmul against a precomputed real-DFT basis (components/dft.py): the STFT basis folds in the checkpoint's window and is built when the streamer is (after load, before capture); the n=16 inverse basis is cached per device and first built in the region's eager warmup. float64 keeps TF32 and autocast out of it. The eager paths keep torch.stft / irfft, so one-shot output stays bitwise #594's and main's Perception, and the eager streamer stays bitwise #594's. GPU, 1x H100 PCIe, served checkpoint, regions captured as the engine does: - after cufft_plan_cache.clear() + eager FFT churn + poisoned free memory, the #595 head hits an illegal memory access on its next conformer_step replay; with this change encoder frames (2 clips, single and batched) and codec windows (bs 32) are bitwise unchanged, fp32 and bf16. - matmul vs fp64 reference: STFT within fp32 rounding (torch.stft fp32: 1e-6 on values up to 16); irfft 3e-8 (torch fp32: 2.4e-7); same under TF32 and bf16 autocast. - encoder, captured vs main offline: fp32 min cos 0.9999996 (C1.2 bar 0.9999); bf16 mean|diff| 0.99-1.01x the bf16-vs-fp32 yardstick (bar 2x). Captured vs eager in fp32: max 4.7e-7 (was 2.3e-7 through cuFFT). Codec captured vs eager: 1.8e-7, at most one int16 step. - graph replay at bucket 32: conformer_step 8209 -> 8226 us (bf16), codec_decode 18657 -> 18669 us (bf16): +0.2% / +0.1%. Out of scope: an engine-level guard against FFTs in captures; chatterbox's captured vocoder on main has the same exposure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| if runner is not None and tf == self._steady_window and runner.can_run(len(idxs)): | ||
| wav = runner.run(static_inputs={"codes": batch}, real_bs=len(idxs))["wav"] |
There was a problem hiding this comment.
[P1] One-shot audio also goes through the captured matmul iDFT, so the description's "the eager paths keep torch.stft / irfft, so one-shot output is unchanged" doesn't hold for the codec.
The Talker→Codec edge is FixedChunkPolicy(chunk_size=codec_chunk_frames) in every mode (nemotron_duplex_model.py:750). So in a one-shot call, every chunk after the first has a 3 + 5 = 8-frame window (_steady_window). That window takes this branch and runs _decode_region → codec.decode(..., by_matmul=True) → irfft_dim1 (a float64 matmul against irfft_basis), not torch.fft.irfft. The encoder side does keep one-shot on the eager path, because an upload runs alone in front and the layers run eagerly. One-shot PCM, however, now comes from a different transform than #594's. The "bitwise identical to #594" GPU check is listed under "Before the rebase", so it is unclear whether it ran with commit 5. Please either re-run the one-shot audio comparison on this head and report the result, or change the claim to the measured difference.
There was a problem hiding this comment.
You're right. After a call's first chunk, every 8-frame codec window replays codec_decode → codec.decode(by_matmul=True) → irfft_dim1, one-shot calls included; only the encoder keeps one-shot on the eager torch.stft path.
Description corrected (commit 5): one-shot text is unchanged; one-shot codec audio is no longer bitwise identical to #594's, and that difference on served audio is not measured. The "bitwise identical to #594" check is listed as having run before commit 5. The only measurement is component level, from before the rebase: captured vs eager decode on 8-frame windows at batch 32 differ by at most 1.8e-7, one int16 step, in fp32 and bf16. #770 then makes the matmul iDFT the only path for served codec audio.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
A live step at 32 calls spent ~19 ms (in isolation; ~50 ms beside the worker's other threads) launching the encoder's ~1000 eager kernels, which put the live path past the 80 ms tick from 24 calls up. One-shot encodes a call once, so it never paid this. Each call's layer state (attention keys/values, conv inputs) moves into a RecurrentStatePool (ENCODER_STATE), and the layer stack runs over device counts, so a step where every call has at most one new frame (every live step) replays it as a piecewise CUDA graph. Steps with more frames (a one-shot clip, a large append, the call's end) run the same function eagerly. RecurrentStatePool gains gather/scatter_ (as in #383), and its addressing gains narrow: a chunk of more than a second still runs apart from the batch (no other call is padded to it), now on its own row of the step's plan. Also turns off per-message deflate on /generate/ws: PCM barely compresses, and it cost the event loop ~8% at 32 live calls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Live calls start at different times, so their 5-frame codec chunks come due on different frames: at 24 staggered calls the codec ran ~5x the steps of a lockstep load (168 vs 32), ~4 rows each, every one an eager decode. A steady-state window (3 context frames + one chunk) now replays a piecewise CUDA graph per batch bucket; other window lengths (a call's first chunk, the flush) stay eager. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| conformer_encoder encoder_state, 16 kHz speech -> per-frame LLM embeds (+ RNN-T); per | ||
| encoder_front session, its layers' state and its audio / mel tails |
There was a problem hiding this comment.
[P2] The module docstring doesn't mention encoder_kv, the third resource commit 7 added to the encoder.
The node table lists conformer_encoder as declaring only encoder_state and encoder_front. A few lines down (line 15), "The encoder keeps a call's state in its two pools" says the same. But get_node_resources now also gives the node ENCODER_KV, which holds the attention window and runs the attention. That is the table this docstring claims to mirror, and it is the commit's main change (AGENTS.md S2, stale comments). Line 15 needs the same fix, e.g. "in its bounded KV and two pools".
| conformer_encoder encoder_state, 16 kHz speech -> per-frame LLM embeds (+ RNN-T); per | |
| encoder_front session, its layers' state and its audio / mel tails | |
| conformer_encoder encoder_kv, 16 kHz speech -> per-frame LLM embeds (+ RNN-T); per | |
| encoder_state, session, its attention window, its layers' conv state | |
| encoder_front and its audio / mel tails |
Automated review🔴 1 P1, 1 P2 on Commit 7 moves the encoder's attention window onto the bounded KV (#816). The earlier one-shot codec P1 and the stale Description
What the review checked
Not reviewed: Advisory, not a merge gate. Rules: |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Gaurav-Shah05
left a comment
There was a problem hiding this comment.
LGTM, I only have a nit added below
| # each call's step on its own (declare, admit, plan, forward, commit). | ||
| # Such chunks are uploads and large appends, so it is a rare step. | ||
| longest = int(self.BATCHED_CHUNK_S * self.perception.config.sample_rate) | ||
| return len(model_inputs) == 1 or all(inp.input_seq_len <= longest for inp in model_inputs) |
There was a problem hiding this comment.
since can_batch runs after the rows are picked, one chunk over the untuned 1 s BATCHED_CHUNK_S runs every live call in its step one at a time. I think the batch-grouping follow-up belongs in the scheduler's capture groups instead of Engine.exec, which needs capture groups to work without a whole-forward capture and to see each chunk's length.
There was a problem hiding this comment.
I agree. This will go in a followup
Conflicts: - mstar/engine/resources/kv/bounded/config.py, manager.py, test/modular/test_bounded_kv.py (add/add): took main's, which is this branch's bounded KV as it landed in #816 plus the max_span and max_slots checks; the branch-only BoundedKVConfig.slot_bytes had no caller. - mstar/engine/resources/recurrent/pool.py: main's gather / scatter_ (#891), which follow the step's plan by label and read a row with no saved state as zeros (or `start`), replace this branch's (name, rows, layer) version; from_spec keeps this branch's dtype=None resolution and passes main's resource-key name; main's has_state comment. - docs/adding_models.rst: kept the bounded-KV paragraph's encoder example; the two RecurrentStateSpec rows (this branch's, describing the old gather API, and main's) are now one row. Adapted to main (no conflict markers): - mstar/model/nemotron_duplex/components/conformer.py, submodules.py: the encoder's front end and layer stack call main's gather / scatter_ without an addressing argument. The rows / front_rows parameters and the _pool helper that threaded them are deleted. Every slot those calls read is admitted zeroed, so main's zero-fill for rows with no saved state reads the same values; padding rows read zeros now instead of the sink, and their outputs are discarded. - test/modular/test_recurrent_padding_rows.py: dropped this branch's four tests of the old gather / scatter_ (main's test_recurrent_gather_scatter.py covers the landed API). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#891's gather / scatter_ Main's RecurrentStatePool.gather / scatter_ (#891) take no addressing: they read the plan the pool holds when they run, so the test's wrapper, written for the (name, rows) API, broke the call (TypeError) rather than checking anything. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> # Conflicts: # configs/nemotron_duplex.yaml # configs/nemotron_duplex_disagg.yaml
… gets a 503 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t back build() wrote the engine's kv_dtype into the model's RecurrentStateConfig, which its specs (and a second engine) share. Resolve it into the pool's own blocks instead, as the bounded KV does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The config's byte counts size a dtype=None block in torch's default dtype now that the pool no longer writes its resolved dtype back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The factory read the block's declared dtype from the shared config, which is None for a block in the engine dtype since P3a. RecurrentStatePool. block_dtype is the one rule the pool allocates by, and the factory asks it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
numel, nbytes, slot_bytes and total_bytes had no caller since the pool sizes its startup log from its own tensors (P3a), and were wrong for a dtype=None block. Their only test restated them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rule moves from the static RecurrentStatePool.block_dtype to RecurrentBlockConfig.resolved_dtype, which the pool and the linear-attention builder both call, so linear_attn/base.py no longer imports the pool. The to_blocks state_dtype hints admit None (the engine's dtype). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts: none. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stack: full duplex (#186), part 5 of 6. Stacked on #594. It also carries #816's 20 commits (bounded KV), cherry-picked onto this stack's base. #816 merged on 2026-10-23, so they leave the diff once main is merged into the stack. The whole stack is listed at the end.
What this does
This PR makes live calls cost close to what one-shot calls cost.
Profiling on one H100 found that the difference was the duplex encoder, not the engine or the rest of the model. One-shot encodes a call once. Live mode steps the encoder on every 80 ms append, and each step was about 1,000 small eager kernels plus per-call Python. At 32 calls that cost 16–24 ms of GPU-thread host time, on a worker that is CPU-bound. LLM, talker and codec steps cost the same in both modes.
What changed in this revision
This revision adds commit 7, plus #816.
The encoder's attention window moved off the model and onto the engine's bounded-KV resource (#816).
ENCODER_STATE. The model gathered that block, appended the new frame, scattered it back, and ran its own relative-position attention (_attend/_mask).ENCODER_KV(aBoundedKVSpec, per-query window 70, one slot per session). Each step declares its calls' positions and new frames._attendcallskv.attend(q + u, k, v, rel_bias=...). The resource stores the window and runs the attention (Triton).framesinput.Most of the performance numbers below are from before this change, and were not re-measured in full. The one check on this revision is a short interleaved A/B on one H100 ("This revision" under "How it was tested"). It has 2 runs per arm, and no profiling pass on the new arm. It shows the new encoder level with the old one on live calls alone, and ahead with uploads mixed in. That's what I'd expect, since the per-layer gather / scatter of the whole window is gone. But two runs is not a verdict. The correctness gate was rerun on the stack's steps.
How it works
The PR is seven commits.
RecurrentStatePool(ENCODER_STATE);ENCODER_KV).conformer_layers).RecurrentStatePoolgainsgather/scatter_, the same methods as MiniCPM-o 4.5: speech output, CUDA graphs, fused vocoder, benchmark entry #383./generate/ws. PCM barely compresses, and deflate cost the event loop about 8% at 32 calls.codec_decode). Calls that start at different times reach chunk boundaries on different frames. So a live load runs about 5× as many small decodes as one where all calls run in lockstep._spec_to_wavused to assert the envelope on every decode. That was a host sync on every codec step, one-shot included. It also made the decode impossible to capture.ENCODER_FRONT). Host counters replace the buffers that used to hold the whole call.conformer_step): preemphasis, STFT, mel, subsampling and layers.conformer_stepandcodec_decodereplay got destroyed (an illegal memory access on H100).torch.stft.FixedChunkPolicyin every mode. So after a call's first chunk, every 8-frame window replayscodec_decode(_decode_region→codec.decode(by_matmul=True)→irfft_dim1), one-shot calls included. The first chunk, and any window with graphs off, keepirfft. So one-shot PCM is no longer bitwise identical to nemotron_duplex: live input over a client stream, cache-aware streaming encoder (full duplex 4/6) #594's. How far it differs on served one-shot audio is not measured.codec_decodeand the eager decode differ by at most 1.8e-7, or one int16 step, in fp32 and in bf16.can_batch(withinput_seq_lenin samples) keeps a chunk over 1 s (an upload, a large append) out of a batch. The engine then runs that step one call at a time.RecurrentAddressing.narrow, which let the forward split rows itself, is removed.prepare_inputs. The declaration and the forward both read that count.validate_config_yamlcountsencoder_kvin the session-capacity check.Engine changes
Outside
nemotron_duplexand the API server (the deflate setting), the engine changes are #816's bounded KV (merged; carried here until main is merged into the stack), and these in the recurrent pool:RecurrentStatePool.gather/scatter_(commit 1). They refuse padding rows on a pool without a sink slot.RecurrentBlockConfig.dtypemay beNone, meaning the engine's compute dtype.RecurrentStatePool.buildresolves it in place toinfo.kv_dtype(torch's default dtype if that is unset). This is the same way it already appliesshardto the spec's config.engine_managerpasseskv_cache_type=autocast_dtype, so this is the autocast dtype the layers compute in.RecurrentAddressing.num_rowsnow says it includes a replay's padding rows, which is whatplanalready sets.How it was tested
This revision
0b7b7de9c: commit 7 on top of #816's commits. One H100 80GB HBM3, torch 2.12.1+cu132 (CUDA 13.2), Triton 3.7.1,mstar_rustbuilt from this tree.ruff check .: clean.CPU Core (
OMP_NUM_THREADS=2, everyCPU_TEST_PATHSdirectory, without xdist; see below for why): 6864 tests, 4 failed. The previous head246222c91in the same environment fails the same 4: twotest_eval_compare.pyprocess-stop cases,test_e2e_validators.py's SIGHUP case, andtest_serving_metrics.py's case, which needsprometheus_client.On CUDA: the bounded-KV tests, 89 passed. Together with the nemotron_duplex submodule and model tests, 221 passed. They include a step that mixes the whole-step region with an eager chunk longer than the window, and a dropped capture bucket falling back to eager.
Correctness gate (
correctness.py, outside the repo, against mainabdfd5802). Steps: configs, encoder parity, main a/b, stack a/b, graphs off, edge cases, and main's standalone reference for margins.turn_takingwhole-clip mean |d| 6.35e-4 vs 6.48e-4).interruptionsframe 329 (,vs., margin 0.375). A near-tie, the same as on the previous head.turn_takingframe 204. An upload drops a "Here" that main and live calls emit. Margin 1.31 in main's standalone run, just over the gate's 1.0 near-tie bar.Live A/B. Interleaved fresh servers,
246222c91vs this revision. First 32 paced calls spread over 2 s. Then the same with a 20 s upload every 2 s and 36 slots, so uploads share steps with live calls. Lag p50 / p95 / p99, then drain p50:246222c910 failed calls and 21/21 uploads on both. With two runs per arm and no profiling pass on the new arm, read this as "no regression seen", not as a measured speedup.
Previous head
246222c91This head includes #766's and #593's fixes. CPU only, on a 24-vCPU Xeon Platinum 8488C, Python 3.12.13, torch 2.12.1+cu132, pytest 9.1.1, with
mstar_rustbuilt from this tree.ruff check .: clean.OMP_NUM_THREADS=2 python -m pytest <CPU_TEST_PATHS> -p xdist -n 4): 6154 passed, 3 failed, 612 skipped. Mainabdfd5802in the same environment: 6061 / 3 / 612, with the same 3 failures.test_serving_metrics.py::test_refusals_count_from_when_metrics_are_turned_onneedsprometheus_client, which[all]doesn't install (CI installs[metrics]). The twotest_eval_compare_multigpu.py::test_dump_stacks_retries_with_sudo_only_when_askedcases find no processes under nohup + xdist. All three pass on main once the environment is fixed.test/modular/test_piecewise_config_signature.pyran outside xdist. It parametrizes over a set, so xdist workers collect it in different orders (on main too). It passes.ff5002e7e, Client streams: the client as a streaming edge into a running request (full duplex 3/6) #593's0ed426659and this PR's docstring fixe4030eefb. None of them changes nemotron_duplex's code.main, so it will run on this PR once the PRs below it merge.The correctness gate harness
correctness.py, kept outside the repo. It runs one-shot vs main, live vs one-shot, batching up to 32 calls, graphs off, and the protocol edge cases. Each is checked against criteria fixed before the run. The H100 subset below rancorrectness.py all --steps prep,p0_configs,srv_main_a,srv_main_b,srv_stack_a,srv_stack_b,srv_stack_eager,srv_stack_edge,p8_gate.Latest GPU run of the previous head
One H100 80GB HBM3, torch 2.12.1+cu132 (CUDA 13.2). The whole stack through #770 at its previously pushed head, against main
d519553db.interruptionsdiffers at frame 329 (,vs.). That is the same kind of bf16 knife-edge as before. The stack's output there is unchanged from the previous revision's run, and main's own output flips at nearby frames between H100 and H200.conformer_step,conformer_layers;codec_decodeis gone in nemotron_duplex: per-frame codec in the talker's step (full duplex 6/6) #770), with no bucket dropped.end_input: ["audio_in"]ends the call the same astrue;client_streams.audio_in.max_items(it used to be a 500).Earlier revisions
test_nohup…,test_ctrl_c…andtest_sigterm…(timing-flaky on both trees), andtest_refusals…(needsprometheus_client). 6135 passed and 607 skipped, against main's 5887 passed.321763dd8, which differs from the heads pushed then only in test files, a startup guard and comments), against maind519553db, with the same gate harness:torch.stft/irfft, and the stack's captured regions run no FFT.end_input, append from another socket, the input budget, the session cap, abort mid-call and recovery, 409 on a live id, null fields, and upload channel / rate / length refusals;ruff check .: clean.Performance
Measured before commit 7 (see "What changed in this revision", and "This revision" under "How it was tested").
All runs are on one H100, torch 2.12.1+cu132, with
MSTAR_SHM_ARENA=1, before the rebase. A/B arms were interleaved, on one server process each, and repeated.Harnesses (kept outside the repo):
enc_bench.py --calls 32. It runs the streaming encoder alone, stepped the way the server steps it.MSTAR_PHASE_TIMINGphases.ab.py --rounds 2, the interleaved paced A/B. Arms alternate on fresh servers, with 5 loads at 16–32 calls per round.Encoder step at 32 calls: host time 23.6 → 8.4 ms.
GPU-thread work per output frame at 32 calls (one-shot is 24–48 ms on the same pod):
Live lag (paced 80 ms appends): 2–6× lower than nemotron_duplex: live input over a client stream, cache-aware streaming encoder (full duplex 4/6) #594 in 9 of 10 interleaved pairs at 16–32 calls.
Against vLLM-Omni and SGLang-Omni
Setup:
d519553db(48f8f69edwith Keep a fresh request's final stream chunk in a speculative step (full duplex 1/6) #769's fix, on mainfa37d227c),configs/nemotron_duplex.yaml,MSTAR_SHM_ARENA=1.nemotron_labs_voicechat_duplex.yaml, SGLang-Omni 0.1.8examples/run_nemotron_voicechat_duplex.py.compete.py(phasessingleandcapacity, paced clientrt_client.py), kept outside the repo. Its verdict rules were fixed in its docstring before the run:Response latency: from the user stopping to the first voiced agent audio reaching the client (s, median over server processes).
compete.pyexcludes every latency cell and makes no latency claim against either engine. The medians above are what was measured: mstar was 0.04–0.43 s behind vLLM-Omni on every clip.demo_8s). mstar's server lag p50 is 0.206 s here, against vLLM-Omni's 0.010–0.090 s. That points at mstar's 5-frame codec chunks. nemotron_duplex: per-frame codec in the talker's step (full duplex 6/6) #770 decodes one frame per step inside the talker, and its build measured 0.91–2.15 s on the same pod (see nemotron_duplex: per-frame codec in the talker's step (full duplex 6/6) #770; also no verdict).Real-time sessions on
demo_8swith this PR, ascompete.pyjudges them (every session finished in every row):compete.py's sustained count (the largest N where every N' ≤ N is in real time) is 1 lockstep / 16 staggered.compete.pymarks a capacity claim against it as not shown. The lockstep counts are both 1.--max-sessions 8(with or without--talker-attention triton).compete.pycalls this PR ahead of it.MSTAR_SHM_ARENA=0, only 1 and 32 sessions were run. 1 keeps up, 32 doesn't.Caveats:
nvccwas installed for it alone.Follow-ups
can_batchrefuses a batch,Engine.execfalls back to running one request at a time. It should split the batch into groups instead, each a batched sub-step with its own region leases. Then a step holding one long chunk would run that chunk alone and keep the rest batched. The encoder would declare a chunk over 1 s as its own group. Flux2 Klein, LTX-2.5 and Chatterbox fall back to per-request on mixed inputs today, and would gain too.max_session_s(160 s) at the same time. The talker pool alone is 1,024 pages of 128 positions, about 28 GiB for 32 sessions. This PR sizes and checksnano_kvthe same way.max_session_slowers how many sessions fit.RetentionPolicy,mstar/engine/windowing.py) can free pages behind a window. But it only helps layers that read a window. The LLM's attention layers and the talker's full-attention layers read the whole call, and the talker's sliding window (7,500 positions) is longer than any session.RecurrentStatePool.gather/scatter_here have the same names and signatures as MiniCPM-o 4.5: speech output, CUDA graphs, fused vocoder, benchmark entry #383's. Whichever lands second keeps this PR's check for padding rows that a pool without a sink slot can't address.Stack
🤖 Generated with Claude Code