What this page is. A complete, copy-paste, for-dummies guide to serving
laion/moss-tts-local-transformer-4.55b-voice-acting
on a single NVIDIA A100-80GB with SGLang-Omni — the OpenAI-compatible speech server. It covers the exact
install, the exact launch command, every trap we hit (with the verbatim error text), the full HTTP API, and a
measured speed & tuning chapter with the knobs that maximize throughput for offline dataset generation.
Every number on this page was measured on one A100-80GB (SXM) with torch 2.9.1+cu128, SGLang-Omni 0.5.8, flashinfer 0.6.1, Python 3.12. Read numbers as "per one A100"; the model is small enough that one card is the natural unit.
SGLang-Omni is a serving framework for omni / speech models built on top of SGLang. It runs the model as a pipeline of stages and exposes them behind an OpenAI-compatible HTTP server. For this model the pipeline is:
# three stages, one GPU preprocessing # tokenize text, encode any reference audio with the ~1B causal codec -> tts_engine # the 4.55B Qwen3 backbone + 12-codebook local-transformer, run by SGLang -> vocoder # MOSS-Audio-Tokenizer-v2 decodes RVQ codes back to 48 kHz stereo PCM
Why this is the fast path for the 4.55B (versus a plain transformers generate loop):
tts_engine stage is a real SGLang scheduler: independent requests are
packed into a single running batch and admitted/retired token-by-token, so the GPU stays busy instead of idling
between requests. This is the entire reason offline throughput scales to tens of times realtime.moss_tts_local architecture is implemented natively, including a seeded
sampler that is reproducible at any batch size — a fixed seed gives the same audio at concurrency
1 or 128.nvcc if you enlarge the
CUDA-graph batch sizes (see trap 7) — the stock config does not./tmp
(see trap 5).We use uv. Install it once (curl -LsSf
https://astral.sh/uv/install.sh | sh), then:
# create the venv ON A REAL DISK (see trap 5) — /home, not /tmp uv venv /home/$USER/sglang_moss_venv --python 3.12 source /home/$USER/sglang_moss_venv/bin/activate # install SGLang-Omni (this pulls torch 2.9.x+cu128, sglang, flashinfer, transformers, soundfile, ...) uv pip install sglang-omni # soundfile is needed to decode base64 / data-URI reference audio uv pip install soundfile
Key package versions this guide was validated against (yours may be newer):
| package | version | why it matters |
|---|---|---|
| python | 3.12 | runtime |
| torch | 2.9.1+cu128 | ships its own CUDA 12.8 runtime — no system CUDA needed to serve |
| sglang-omni | 0.5.8 | the server + moss_tts_local pipeline |
| flashinfer-python | 0.6.1 | attention kernels + CUDA-graph capture |
| transformers | ≥4.45 (remote-code) | the model's custom code + processor |
| soundfile | latest | decode reference audio for voice cloning |
| flash-attn | NOT installed | not needed; FA2 path is broken for this remote code |
# the LAION 4.55B voice-acting checkpoint (merged weights, Apache-2.0, public) hf download laion/moss-tts-local-transformer-4.55b-voice-acting # the v2 audio tokenizer / vocoder (pulled automatically on first run, or pre-fetch it) hf download OpenMOSS-Team/MOSS-Audio-Tokenizer-v2
First run also JIT-compiles a flashinfer attention kernel into ~/.cache/flashinfer and captures
CUDA graphs; expect the first startup to take a couple of minutes longer than later ones.
SGLang-Omni selects the pipeline from a tiny YAML. The stock reference-less / voice-clone config is:
# examples/configs/moss_tts_local.yaml
config_cls: MossTTSLocalPipelineConfig
model_path: OpenMOSS-Team/MOSS-TTS-Local-Transformer-v1.5
relay_backend: shm
The model_path inside the YAML only names the architecture family; the actual weights come from
the --model-path you pass on the command line (the LAION checkpoint). The three stages
(preprocessing → tts_engine → vocoder) are filled in by MossTTSLocalPipelineConfig's
defaults, so you do not have to spell them out. relay_backend: shm uses shared memory to move audio
between stages (see trap 3).
cd /path/to/sglang-omni # repo dir that holds examples/configs/ env -u LD_LIBRARY_PATH \ # TRAP 1 — strip a leaked cuDNN path or the 1st request SIGABRTs CUDA_VISIBLE_DEVICES=6 \ # pin to one GPU sgl-omni serve \ --model-path laion/moss-tts-local-transformer-4.55b-voice-acting \ # the LAION weights --config examples/configs/moss_tts_local.yaml \ # the pipeline --port 8000
| token | what it does |
|---|---|
| env -u LD_LIBRARY_PATH | Removes an inherited LD_LIBRARY_PATH so stage subprocesses don't pick up a foreign cuDNN. Mandatory (trap 1). |
| CUDA_VISIBLE_DEVICES=6 | Which physical GPU to use. The process then sees it as cuda:0. |
| --model-path | HF id or local dir of the real weights to load (the LAION 4.55B). |
| --config | The pipeline YAML from 2.3. |
| --port | HTTP port for the OpenAI-compatible API. |
When it is up you will see Uvicorn running on http://0.0.0.0:8000 and
Application startup complete. Sanity-check:
curl -s http://localhost:8000/v1/models
Note (trap 6): /v1/models reports the base model id
(OpenMOSS-Team/MOSS-TTS-Local-Transformer-v1.5) — this is cosmetic. The LAION weights you passed with
--model-path are the ones actually loaded and generating.
For a long-lived server, detach it and log to a file:
# start, fully detached, logging to a file cd /path/to/sglang-omni nohup env -u LD_LIBRARY_PATH CUDA_VISIBLE_DEVICES=6 \ sgl-omni serve --model-path laion/moss-tts-local-transformer-4.55b-voice-acting \ --config examples/configs/moss_tts_local.yaml --port 8000 \ > ~/moss_server.log 2>&1 & echo $! > ~/moss_server.pid # remember the pid # health / readiness curl -s http://localhost:8000/v1/models >/dev/null && echo UP || echo "not ready" # watch the log tail -f ~/moss_server.log # stop it cleanly (kills the stage subprocesses too) kill "$(cat ~/moss_server.pid)"
Stopping gotcha. The server spawns one stage subprocess that holds the GPU memory. If you
kill -9 only the parent, that child can survive and keep ~60 GB pinned. Always verify with
nvidia-smi --query-compute-apps=pid,used_memory --format=csv and kill any orphan before relaunching, or
you will hit a spurious CUDA out of memory on the next start.
POST /v1/audio/speech)OpenAI-compatible speech endpoint. Output is 48 kHz stereo 16-bit WAV — use it raw, no post-processing.
curl -X POST http://localhost:8000/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{"input": "The lighthouse blinked twice before the storm rolled in."}' \
--output out.wav
Python:
import requests
r = requests.post("http://localhost:8000/v1/audio/speech",
json={"input": "The lighthouse blinked twice before the storm rolled in."})
r.raise_for_status()
open("out.wav","wb").write(r.content)
This is a voice-acting model: put the performance direction in instructions.
curl -X POST http://localhost:8000/v1/audio/speech -H "Content-Type: application/json" -d '{
"input": "You dare enter my lair?!",
"instructions": "A furious ancient dragon, low and booming, building to a roar. Includes a growl.",
"language": "English"
}' --output dragon.wav
Pass a reference clip in references (or the ref_audio/ref_text shorthand).
audio_path may be a local path, an HTTP(S) URL, or a base64 data URI. Supplying the transcript
(text) materially improves cloning.
curl -X POST http://localhost:8000/v1/audio/speech -H "Content-Type: application/json" -d '{
"input": "Get the trust fund to the bank early.",
"references": [{
"audio_path": "https://huggingface.co/datasets/zhaochenyang20/seed-tts-eval-mini/resolve/main/en/prompt-wavs/common_voice_en_10119832.wav",
"text": "We asked over twenty different people, and they all said it was his."
}]
}' --output clone.wav
Base64 data-URI form (decoded server-side with soundfile):
{"ref_audio": "data:audio/wav;base64,UklGR....", "ref_text": "Transcript of the clip."}
Set "stream": true to receive SSE. Each event is
data: {"object":"audio.speech.chunk", "audio":{"data":"<base64 WAV>"}}; the final event has
audio: null, then data: [DONE].
curl -N -X POST http://localhost:8000/v1/audio/speech -H "Content-Type: application/json" -d '{
"input": "This sentence is streamed back as it is generated.",
"stream": true
}'
Streaming granularity for this model. The moss_tts_local pipeline emits a
coarse first chunk — for a one-sentence prompt the whole utterance arrives in the first audio event, so
time-to-first-audio ≈ full generation time (see 7.1). The generic
initial_codec_chunk_frames request field exists in the protocol but is only honored by other TTS
pipelines (e.g. higgs), not by moss_tts_local — we tested values 2/4/8 and the first-chunk size and
TTFA did not change. If you need true low-latency token-by-token streaming, that knob is not (yet) wired for this
model.
The model conditions on a target codec-frame count. Set it inline with ${token:N} (stripped
before synthesis) or with the token_count parameter (aliases duration_tokens,
tokens). Larger N → longer audio. Omit it to let the model choose.
{"input": "${token:150}A sentence with an explicit duration target.", "ref_audio": "..."}
| param | default | notes |
|---|---|---|
| input | (required) | text; may carry ${token:N} and inline markup ([pause 0.5s], IPA…) |
| references / ref_audio,ref_text | null | voice-clone reference clip + transcript |
| instructions (alias instruct) | null | free-text performance direction |
| language | null | language hint; omit to auto-detect |
| stream | false | SSE streaming |
| token_count / duration_tokens | null | target duration in codec frames (>0) |
| temperature / top_p / top_k | 0.8 / 0.8 / 30 | server defaults; per-channel text_*/audio_* overrides accepted |
| repetition_penalty | 1.1 | audio repetition penalty |
| seed | null | non-negative int; reproducible at any concurrency |
| max_new_tokens | 4096 | hard cap on generated frames |
Symptom: the server starts fine, then the first request kills a stage subprocess with a
SIGABRT and an error likeundefined symbol: cudnnGetLibConfig (or a cuDNN version mismatch abort).
LD_LIBRARY_PATH (e.g. from a conda "ml-general" env) that
points at a different cuDNN. It is inherited by the stage subprocesses and collides with the cuDNN that torch
ships, so the dynamic loader binds the wrong library and aborts on first use.env -u LD_LIBRARY_PATH in front of sgl-omni (as in §3). This
strips the variable for the server and all its children.out.wav instead of a
500/subprocess death.Symptom: you go looking for docs/cookbook/moss_tts_local.md and it doesn't exist.
docs/cookbook/moss_tts.md (it documents the MOSS-TTS
family; the local-transformer pipeline shares the same /v1/audio/speech surface).docs/cookbook/moss_tts.md. There is no _local variant of that file.libibverbs.so.1 / mooncake import error (harmless)Symptom: at startup:
Failed to import mooncake: libibverbs.so.1: cannot open shared object file … MooncakeRelay will not work.
relay_backend: shm (shared memory), which is what you
want on a single host. Nothing to install.Symptom: Ignore import error when loading sglang.srt.models.glmasr: cannot import name
'GlmAsrConfig' from 'transformers'.
/tmp fills up / disappearsSymptom: install fails with no space left on device, or the venv/model vanishes after a reboot.
/tmp is a small, volatile tmpfs (RAM-backed). The venv + torch +
model cache are many GB./home). Only put
throwaway logs/outputs on /tmp./v1/models shows the "wrong" model nameSymptom: /v1/models returns OpenMOSS-Team/MOSS-TTS-Local-Transformer-v1.5,
not the LAION id — "did it load the wrong weights?"
--model-path (the LAION 4.55B) are the ones loaded and
generating. If you want the name to match, pass --model-name.FileNotFoundError: 'ninja' only when you enlarge CUDA graphs (tuning)Symptom: the stock server starts fine, but the moment you add larger
cuda_graph_bs for tuning, startup dies during graph capture with
FileNotFoundError: [Errno 2] No such file or directory: 'ninja' (deep in a flashinfer
get_batch_prefill_module → run_ninja stack).
ninja and a real nvcc on PATH. The stock config's batch
sizes are already compiled into ~/.cache/flashinfer, so a normal serve never needs them.uv pip install ninja) and put a full
CUDA toolkit's nvcc on PATH for the launch:
PATH="$VENV/bin:/usr/local/cuda/bin:$PATH" CUDA_HOME=/usr/local/cuda sgl-omni serve …. The pip
nvidia-cuda-nvcc-cu12 wheel ships only ptxas, not the full nvcc — you need a
real toolkit.Capturing batches (bs=64 …) down to bs=1 and then
Application startup complete.All measurements: one A100-80GB, bf16, reference-less, a fixed pool of short (1-sentence) and medium (3-sentence) English prompts, mean clip ~9 s. Client is the async benchmark in this repo's serving notes. We define RTF = audio-seconds produced per wall-second — numerically equal to audio-hours generated per GPU-hour. Every run is preceded by a 5-request warm-up so CUDA-graph capture and lazy init don't pollute the numbers.
Streaming, concurrency 1, 20 requests. TTFA = wall time from request send to the first audio SSE event.
| config | TTFA min | TTFA p50 | TTFA p95 | first-chunk audio |
|---|---|---|---|---|
| stock (CUDA graphs on) | 0.74 s | 1.34 s | 5.1 s | ~5 s (whole short clip) |
| + initial_codec_chunk_frames=2/4/8 | 0.79 s | 1.35 s | 4.3 s | ~6 s (unchanged) |
What actually moves TTFA here. For a one-sentence prompt the pipeline emits the entire utterance as the first chunk, so TTFA ≈ full generation latency (~0.7–1.3 s for a short clip). The knobs:
disable_cuda_graph) reverts to the ~22 ms/frame eager path and roughly triples per-frame time,
inflating TTFA. Leave them on.initial_codec_chunk_frames is
accepted by the API but ignored by the moss_tts_local vocoder (verified: first-chunk size and TTFA
unchanged at 2/4/8). There is no config knob to make the first chunk smaller for this pipeline, so sub-500 ms
TTFA on long inputs is not achievable today — you'd need the vocoder to implement chunked emission.Best TTFA achieved: ~0.74 s (short prompt, conc 1, stock CUDA-graph config). The lever was CUDA graphs, which are already on by default.
Streaming, stock server (max_running_requests=16), 48 requests per point.
| concurrency | RTF (aud-s/s) | req/s | TTFA p50 | p95 latency |
|---|---|---|---|---|
| 1 | 3.86 | 0.47 | 1.34 s | 3.54 s |
| 4 | 9.20 | 1.09 | 2.40 s | 9.14 s |
| 8 | 12.88 | 1.49 | 4.05 s | 9.88 s |
| 16 | 15.03 | 1.50 | 6.16 s | 15.11 s |
| 32 | 16.58 | 2.27 | 10.92 s | 15.84 s |
| 64 | 16.01 | 2.21 | 12.75 s | 19.52 s |
Streaming saturates around 16–17× realtime at concurrency 16–32. Past that, aggregate RTF flattens and TTFA balloons (requests queue), so for interactive streaming keep concurrency ≤16 per GPU. Streaming caps lower than offline because SSE framing + the coarse chunking add per-request overhead and the stock server's AR batch is capped at 16.
Non-streaming, sweep the number of in-flight requests (SGLang continuous batching). Three server configs:
max_running_requests=16, CUDA graphs bs≤16, mem_fraction=0.5.max_running_requests to 64 but leave graphs at bs≤16.max_running_requests=64 and capture CUDA graphs up to
bs 64 (cuda_graph_bs=[1,2,4,8,16,24,32,48,64], cuda_graph_max_bs=64),
mem_fraction=0.55.| concurrency | stock RTF | +MRR only RTF | +MRR+graphs RTF | +graphs req/s |
|---|---|---|---|---|
| 1 | 4.79 | 3.63 | 4.33 | 0.52 |
| 8 | 14.30 | 13.77 | 13.94 | 1.72 |
| 16 | 16.01 | 17.74 | 17.79 | 2.01 |
| 32 | 19.74 | 13.82 | 20.01 | 2.41 |
| 48 | — | 17.83 | 22.07 | 2.57 |
| 64 | — | 17.76 | 33.20 | 3.74 |
| 96 | — | 16.61 | 35.17 | 3.91 |
| 128 | — | — | 30.25 | 3.11 |
The +MRR+graphs column at concurrency 64/96/128 is the rigorous steady-state run (384 requests per point, so start-up ramp and drain are negligible); the lower concurrencies use 96 requests. Peak is at concurrency 96 — concurrency 128 regresses (queue thrash / longer per-request latency for no extra throughput).
The lesson (this is the whole game):
max_running_requests alone does nothing — it even regresses. With CUDA graphs only
captured up to bs 16, any running batch >16 falls back to the eager per-frame path (~22 ms/frame,
kernel-launch-bound), which cancels the benefit of the extra concurrency. The "+MRR only" column plateaus at ~17–18×.max_running_requests. Once
batches of 32/48/64 are graphed, per-frame cost stays flat while 64 sequences advance per step, and throughput climbs
from ~18× to ~35× realtime.torch.compile (no measurable gain over CUDA graphs for
this inner loop, and long compile), chunked-prefill (prompts are short; prefill isn't the bottleneck),
pushing mem_fraction past ~0.6 on a single card (steals memory from the two codec instances → OOM risk
for no throughput gain). flash-attn is not usable (broken FA2 path).Peak: ~35× realtime = ~35 audio-hours generated per A100-hour, at concurrency 96 with the
+MRR+graphs config, using ~68 GB VRAM and pinning the GPU at ~100% utilization (steady-state, 384-request
run; concurrency 128 regresses to ~30×). Compared with a plain
serialized transformers loop (~1× realtime, one clip at a time), SGLang-Omni's continuous batching +
graphs is a ~35× speedup for bulk generation on the same card.
Applying the tuning: generate a config with the tts_engine stage's
factory_args.server_args_overrides set to {max_running_requests:64, cuda_graph_bs:[1,2,4,8,16,24,32,48,64],
cuda_graph_max_bs:64, mem_fraction_static:0.55} (and, optionally, the vocoder stage's
max_batch_size:16), then launch with ninja + nvcc on PATH (trap 7). The stock YAML's typed
runtime.sglang_server_args only exposes mem_fraction_static; the batch/graph knobs must go
through factory_args.
max_running_requests=16).stream:true.max_running_requests=64, cuda_graph_bs up to 64,
mem_fraction_static=0.55 (launch with ninja+nvcc on PATH).seed per item if you want reproducibility — it holds at any concurrency.At the peak offline throughput of ~35 audio-hours per A100-hour:
N / 35 A100-hours. Example: 1,000 hours of
audio ≈ 1000 / 35 ≈ 29 A100-hours — about 1.2 days on a single A100, or a few hours across 8
cards.(N / 35) × $/A100-hour. At an illustrative $1.50 / A100-hour, 1,000 hours of
audio ≈ 29 × $1.50 ≈ $43. Scale linearly: 10,000 hours ≈ ~286 A100-hours ≈ ~$430.These assume ~9 s mean clips and reference-less generation. Voice cloning adds a one-time reference encode per unique clip (cached), which is negligible amortized over a batch. Very long clips lower RTF slightly (longer sequences, larger KV); very short clips raise req/s but lower audio-hours/hour.