Summary
Tracking/knowledge issue capturing the learnings from adding PersonaPlex / Moshi
full-duplex speech-to-speech support to mobius (Kyutai Mimi codec + Moshi temporal/depformer LM),
plus the real-time browser voice-chat demo. Work landed in #368.
The goal is to make these (sometimes hard-won) findings discoverable for the next
person who exports a streaming codec / dual-stream speech LM, or who needs ORT
real-time inference in Python.
1. Architecture (what the model actually is)
- Mimi codec (SEANet + codec transformer): encoder
(B,1,T)->(B,8,Tf),
decoder (B,8,Tf)->(B,1,T), 12.5 Hz frame rate, 8 codebooks.
Decoder uses ELU residual blocks (not SnakeBeta) + downsample/upsample.
- Temporal transformer: dim 4096, 32 layers, 32 heads, head 128, RoPE θ=1e4,
3000-frame sliding causal context. RMSNorm(eps=1e-8, fp32), SwiGLU
(linear_in 4096->2*11264, linear_out 11264->4096). Fused QKV in_proj_weight (3*4096,4096).
17-channel dual stream: ch0=text, ch1..8=agent audio, ch9..16=user audio,
per-channel delays [0,0,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1].
- Depformer: dim 1024, 6 layers, 16 heads, context 8, no RoPE,
weights_per_step=16 (stacked per-substep linear weights via multi_linear).
Resets KV every temporal frame; 16 substeps generate the 8 agent + 8 user codes.
2. Export gotchas (codec + LM)
- LayerNorm epsilon: Kyutai/Mimi codec transformer uses
eps=1e-5
(create_norm_fn), but mobius LayerNorm defaults to 1e-6. The default was
the single root cause of an early codec parity failure — pass layer_norm_eps
explicitly. (stored as a repo memory)
- Causal Conv1d padding: Kyutai SEANet causal convs pad
left = effective_kernel - stride, not kernel - 1. The latter is wrong for
strided Mimi convs. (stored as a repo memory)
- Mimi fp16 Conv bug -> fix: exporting Mimi in fp16 broke because the conv I/O
dtype didn't match. Fix (5a25dd0): cast audio I/O float32<->compute dtype inside
MimiEncoder/DecoderModel while keeping ONNX graph I/O as float32 PCM. fp16 decoder
then hits ~71 dB SNR. This lets the codec run fp16 alongside the fp16 LM.
- TF32: CUDA parity requires
use_tf32=0; otherwise the matmuls diverge from the
PyTorch fp32 reference enough to fail atol=1e-4.
3. Real-time inference learnings (the big ones)
3a. The temporal KV cache must be device-resident (ORT IO binding)
Feeding the temporal KV cache as numpy each frame makes ORT copy the entire
growing cache host->device every step — an O(N) cost that dominates per-frame
time as the conversation lengthens (per-frame climbed 60ms -> 200ms+, crossing the
80ms real-time budget after ~20s). GQA compute is only ~6ms; the host copy was the
whole problem.
Fix: ORT IO binding — bind present.* outputs to device and reuse them as the
next frame's past.* inputs (double-buffering). The cache never touches host memory;
hidden/text_logits still come back to host for the depformer + sampling.
Result: per-frame temporal cost flat ~6ms regardless of length; end-to-end flat
~49ms/frame (was 60->200), p90 53ms, RTF ~0.61. (landed in #368, commit 7928a81)
Caveat: the device cache grows ~0.5MB/frame (fine for multi-minute demos; very long
sessions would need a periodic reset+re-prime).
3b. GroupQueryAttention >> standard op.Attention on CUDA (even for MHA)
A sliding-window alternative was prototyped: re-export the temporal model with
position_ids (non-GQA path) so the cache could be windowed. Rejected — standard
opset-23 op.Attention on the CUDA EP is ~13x slower than the fused
GroupQueryAttention contrib op (no fused Flash / no in-place KV; unfused MatMul+Add+Attention
~80ms vs ~6ms at ctx=200). GQA gives a large benefit even for MHA (32 q heads = 32 kv heads).
Lesson: prefer the GQA path on CUDA; IO binding (3a), not windowing, is the right fix for the host-copy cost.
3c. GQA can't express a sliding window
With GQA, the RoPE position is derived from seqlens_k = sum(attention_mask) - 1,
bounded by the physical cache length, and cached keys are pre-rotated at absolute
positions. So you cannot slice/window the cache without corrupting RoPE — the temporal
model has no position_ids input on the GQA path. This is why 3b's windowing
required a (slower) re-export.
3d. Env gotcha — CPU-only ORT silently fakes "CUDA"
The conda onnx env has CPU-only onnxruntime; it silently runs on
CPUExecutionProvider even when CUDA is requested, producing misleading "CUDA"
benchmarks. Any GPU perf measurement must use a real onnxruntime-gpu env.
(personal env note; worth a doc line for contributors)
4. Browser real-time demo (examples/personaplex/)
moshi_ort.py — MoshiORT inference class (shared reset_stream()/process_frame()
front door); offline / --stream / --mic CLIs. Imports only numpy+onnxruntime
(no mobius) so it runs under the GPU venv.
server.py — aiohttp WebSocket server: config-first handshake, background warmup,
4-phase voice/persona priming, single-user lock, per-frame budget logging,
per-second stats push (frame_ms rolling avg, RTF, over_budget).
static/index.html — 24kHz mic client with an AudioWorklet ring-buffer player
- latency slider + collapsible live-metrics panel (jitter buffer, rebuffers, RTF).
- Persona: record / upload / preset voice priming supported.
References
Possible follow-ups
Summary
Tracking/knowledge issue capturing the learnings from adding PersonaPlex / Moshi
full-duplex speech-to-speech support to mobius (Kyutai Mimi codec + Moshi temporal/depformer LM),
plus the real-time browser voice-chat demo. Work landed in #368.
The goal is to make these (sometimes hard-won) findings discoverable for the next
person who exports a streaming codec / dual-stream speech LM, or who needs ORT
real-time inference in Python.
1. Architecture (what the model actually is)
(B,1,T)->(B,8,Tf),decoder
(B,8,Tf)->(B,1,T), 12.5 Hz frame rate, 8 codebooks.Decoder uses ELU residual blocks (not SnakeBeta) + downsample/upsample.
3000-frame sliding causal context. RMSNorm(eps=1e-8, fp32), SwiGLU
(linear_in 4096->2*11264, linear_out 11264->4096). Fused QKV
in_proj_weight (3*4096,4096).17-channel dual stream: ch0=text, ch1..8=agent audio, ch9..16=user audio,
per-channel delays
[0,0,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1].weights_per_step=16(stacked per-substep linear weights via multi_linear).Resets KV every temporal frame; 16 substeps generate the 8 agent + 8 user codes.
2. Export gotchas (codec + LM)
eps=1e-5(
create_norm_fn), but mobiusLayerNormdefaults to1e-6. The default wasthe single root cause of an early codec parity failure — pass
layer_norm_epsexplicitly. (stored as a repo memory)
left = effective_kernel - stride, notkernel - 1. The latter is wrong forstrided Mimi convs. (stored as a repo memory)
dtype didn't match. Fix (5a25dd0): cast audio I/O float32<->compute dtype inside
MimiEncoder/DecoderModel while keeping ONNX graph I/O as float32 PCM. fp16 decoder
then hits ~71 dB SNR. This lets the codec run fp16 alongside the fp16 LM.
use_tf32=0; otherwise the matmuls diverge from thePyTorch fp32 reference enough to fail
atol=1e-4.3. Real-time inference learnings (the big ones)
3a. The temporal KV cache must be device-resident (ORT IO binding)
Feeding the temporal KV cache as numpy each frame makes ORT copy the entire
growing cache host->device every step — an O(N) cost that dominates per-frame
time as the conversation lengthens (per-frame climbed 60ms -> 200ms+, crossing the
80ms real-time budget after ~20s). GQA compute is only ~6ms; the host copy was the
whole problem.
Fix: ORT IO binding — bind
present.*outputs to device and reuse them as thenext frame's
past.*inputs (double-buffering). The cache never touches host memory;hidden/text_logitsstill come back to host for the depformer + sampling.Result: per-frame temporal cost flat ~6ms regardless of length; end-to-end flat
~49ms/frame (was 60->200), p90 53ms, RTF ~0.61. (landed in #368, commit
7928a81)Caveat: the device cache grows ~0.5MB/frame (fine for multi-minute demos; very long
sessions would need a periodic reset+re-prime).
3b. GroupQueryAttention >> standard op.Attention on CUDA (even for MHA)
A sliding-window alternative was prototyped: re-export the temporal model with
position_ids(non-GQA path) so the cache could be windowed. Rejected — standardopset-23
op.Attentionon the CUDA EP is ~13x slower than the fusedGroupQueryAttentioncontrib op (no fused Flash / no in-place KV; unfused MatMul+Add+Attention~80ms vs ~6ms at ctx=200). GQA gives a large benefit even for MHA (32 q heads = 32 kv heads).
Lesson: prefer the GQA path on CUDA; IO binding (3a), not windowing, is the right fix for the host-copy cost.
3c. GQA can't express a sliding window
With GQA, the RoPE position is derived from
seqlens_k = sum(attention_mask) - 1,bounded by the physical cache length, and cached keys are pre-rotated at absolute
positions. So you cannot slice/window the cache without corrupting RoPE — the temporal
model has no
position_idsinput on the GQA path. This is why 3b's windowingrequired a (slower) re-export.
3d. Env gotcha — CPU-only ORT silently fakes "CUDA"
The conda
onnxenv has CPU-only onnxruntime; it silently runs onCPUExecutionProvidereven when CUDA is requested, producing misleading "CUDA"benchmarks. Any GPU perf measurement must use a real onnxruntime-gpu env.
(personal env note; worth a doc line for contributors)
4. Browser real-time demo (examples/personaplex/)
moshi_ort.py—MoshiORTinference class (sharedreset_stream()/process_frame()front door); offline /
--stream/--micCLIs. Imports only numpy+onnxruntime(no mobius) so it runs under the GPU venv.
server.py— aiohttp WebSocket server: config-first handshake, background warmup,4-phase voice/persona priming, single-user lock, per-frame budget logging,
per-second
statspush (frame_ms rolling avg, RTF, over_budget).static/index.html— 24kHz mic client with an AudioWorklet ring-buffer playerReferences
justinchu/personaplex-moshiMoshi depformer weights_per_step, codec transformer eps.
Possible follow-ups
(marginal: sampling needs host logits each substep).
the relevant skills.