Skip to content

Learnings: PersonaPlex/Moshi full-duplex S2S export + real-time ORT inference (#368) #369

Description

@justinchuby

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.pyMoshiORT 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

  • Optionally IO-bind the depformer 16-substep loop for extra jitter headroom
    (marginal: sampling needs host logits each substep).
  • Periodic temporal cache reset+re-prime for very long (>10 min) sessions.
  • Document the GQA-vs-op.Attention CUDA perf gap + the CPU-only-ORT env trap in
    the relevant skills.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions