Adding a Model#
A model is implemented once, in Python, as a program, and both runtimes (Serving Runtimes) serve it. Adding one touches no scheduler, worker or engine code:
File |
Contents |
Needed |
|---|---|---|
|
PyTorch modules, weight loading, codec, model constants (a |
usually |
|
the program: how a step runs on the GPU, what sampled tokens mean, prompts, |
yes |
|
the model names that select the program |
yes |
|
a prompt builder in Rust, for high request rates on the Rust runtime |
optional |
|
documentation |
yes |
The shortest complete example is Orpheus (Llama-3.2-3B with the SNAC codec), whose whole program is shown
in step 2. The other programs cover multi-codebook models
(zonos, higgs_tts), depth transformers (csm, qwen3_tts), flow-matching codecs (cosyvoice2,
chatterbox), voice cloning (chatterbox, cosyvoice3), audio input (step_audio_2, glm_voice),
input streaming (qwen3_tts) and a codec that decodes the LM’s hidden states, carried to it in the token rows
(xtts).
How a program runs#
At start-up, the program allocates static buffers (KV cache, per-request sampling state) and declares arenas: named input and output fields in one device buffer each. It then captures every stage of a step as CUDA graphs, one per batch size:
lm_prefill: prompt rows, one graph per row bucket (256,512,1024,2048, up to--max-context);lm_decode: one row per request, for batch sizes 1, 2, 4, … up to--max-batch-size;detok: the codec, which turns a chunk ofdetok_intervalframes intodetok_samplesaudio samples.
Each step, a runtime writes the inputs (tokens, positions, KV page tables, sampling parameters, …) into the
input arena, replays the graphs and reads the output arena: the sampled tokens (out_tok) and their
kind (out_kind: audio frame, stop, or other). The runtime never looks inside the model, and the program
never sees requests, only rows and batch entries. LMProgram (vox_serve/rt/program.py) implements all
of this; a program supplies the model-specific pieces. The conventions (rows fed back on the GPU, the
scratch KV page, padding entries) are described at the top of vox_serve/rt/program.py.
Code that runs inside a captured graph, which is everything a program’s hooks run except load,
preprocess and meta, must follow the rules of CUDA graph capture:
No host synchronization. No
.item(),.cpu(),.tolist(), printing tensors, or Pythonifon tensor values; usetorch.whereand masks.Static shapes. Shapes may depend on the batch size or row bucket being captured, not on data.
State in buffers. Anything kept between steps or chunks lives in a buffer allocated in
allocate_stateand indexed by the request’s slot (arena["slot"]), never in Python attributes.Padding is real work. Unused batch entries (
active == 0) point at a dummy slot and KV page 0 and compute garbage that is ignored; they must not fail or write into other requests’ state.Randomness through the sampler.
sample_intodraws from per-request seeds, so a request samples the same tokens whatever batch it lands in.
1. The model code#
model.py holds the PyTorch side, often adapted from the reference implementation:
Attention goes through FlashInfer’s paged KV cache. Each layer receives
attn_wrapperand the paged cache, writes its keys and values withattn_wrapper.set_kv_cache(kv_cache, k, v)and attends withattn_wrapper.run(q, kv_cache). The cache is laid out[num_layers, num_pages, 2, page_size, num_kv_heads, head_dim].vox_serve/flashinfer_utils.pyhas RoPE and RMSNorm kernels. Start from a model with the same backbone: Orpheus (Llama), CosyVoice2 (Qwen2), Qwen3-TTS (Qwen3).The codec maps token ids
[batch, frames, codebooks]to audio. Shared codecs live invox_serve/tokenizer/(SNAC, Mimi, DAC, HiFT, …) and audio encoders invox_serve/encoder/.Constants: a
BaseLMsubclass (vox_serve/model/base.py) reports the layer and head counts,detokenize_interval(frames per audio chunk),detokenize_overlap(frames consecutive chunks share),output_audio_length(samples per chunk),max_tokensanddefault_sampling_config(avox_serve.sampling.SamplingConfig).
The model class does not run steps, sample or build prompts; the program does.
2. The program#
program.py subclasses LMProgram. The Orpheus program, complete:
"""Orpheus (Llama-3.2-3B + SNAC 24 kHz) graph program."""
from typing import Any, Dict, List
import torch
from ...rt.core import RtAttention
from ...rt.program import LMProgram
from .model import OrpheusModel
class OrpheusProgram(LMProgram):
supports_urgent_lane = True
def load(self) -> None:
name = self.config.get("model", "canopylabs/orpheus-3b-0.1-ft")
if name == "orpheus":
name = "canopylabs/orpheus-3b-0.1-ft"
self.model_name = name
self.model = OrpheusModel(
name, device=str(self.device), audio_decoder_device=str(self.detok_device), tokenizer_path=name
)
self.lm = self.model.model
cfg = self.lm.config
self.n_codebooks = 1
self.vocab_size = cfg.vocab_size
self.num_layers = cfg.num_hidden_layers
self.num_qo_heads = cfg.num_attention_heads
self.num_kv_heads = cfg.num_key_value_heads
self.head_dim = self.model.head_dim
self.detok_interval = self.model.detokenize_interval
self.detok_samples = self.model.output_audio_length
def embed(self, ids: torch.Tensor, arena, rows: int) -> torch.Tensor:
return self.lm.model.embed_tokens(ids[:, 0])
def fuse_lm_modules(self):
return [self.lm.model]
def backbone(self, hidden: torch.Tensor, pos: torch.Tensor, attn: RtAttention) -> torch.Tensor:
return self.lm.model(inputs_embeds=hidden, position_ids=pos, attn_wrapper=attn, kv_cache=self.kv_cache)
def head(self, hidden: torch.Tensor) -> torch.Tensor:
return self.lm.lm_head(hidden)[:, None, :]
def detokenize(self, tokens: torch.Tensor) -> torch.Tensor:
return self.model.postprocess(tokens)
def stop_tokens(self) -> List[int]:
return [self.model.stop_token_id]
def meta(self) -> Dict[str, Any]:
from huggingface_hub import hf_hub_download
sc = self.model.default_sampling_config
return {
"kind": "orpheus",
"model_name": self.model_name,
# `{voice}: {text}` between Orpheus' start-of-human / end-of-human-and-start-of-AI ids.
"prompt_builder": "template",
"prompt_template": {
"tokenizer_json": hf_hub_download(self.model_name, "tokenizer.json"),
"prefix_ids": [128259],
"suffix_ids": [128009, 128260, 128261, 128257],
"text": "{voice}: {text}",
"kwargs": {"voice": {"default": "tara", "choices": list(self.model.available_voices)}},
},
"sample_rate": 24000,
"detok_overlap": self.model.detokenize_overlap,
"max_tokens": self.model.max_tokens,
"sampling": {
"temperature": sc.temperature,
"top_k": sc.top_k or 0,
"top_p": sc.top_p if sc.top_p is not None else 1.0,
"min_p": sc.min_p or 0.0,
"repetition_penalty": sc.repetition_penalty or 1.0,
"repetition_window": sc.repetition_window if sc.repetition_window is not None else -1,
},
}
Required pieces:
load()Builds the model on
self.device(the codec onself.detok_device, a second GPU with--enable-disaggregation) and setsn_codebooks,vocab_size,num_layers,num_qo_heads,num_kv_heads,head_dim,detok_intervalanddetok_samples.self.configholds the command-line options (model,max_batch_size,fuse_lm, …); map the short model name to a Hugging Face id here.embed(ids, arena, rows),backbone(hidden, pos, attn),head(hidden)Token ids
[rows, n_codebooks]to hidden states, the decoder stack over paged attention, and hidden states[N, H]to logits[N, n_codebooks, vocab].self.kv_cacheis the paged cache.detokenize(tokens)[Bd, detok_interval, n_codebooks]token ids to float audio in[-1, 1]withBd * detok_samplesvalues. Consecutive chunks startdetok_interval - detok_overlapframes apart, and each returns only the samples it adds.detok_stepconverts the audio to int16 PCM withpcm16(rt/program.py), which clamps samples outside[-1, 1]; programs that overridedetok_stepconvert with it too.stop_tokens(),audio_tokens()Which codebook-0 tokens end generation, and the
[lo, hi)range that is audio (other tokens, such as interleaved text, are fed back but not decoded). Overridetoken_kind(tok)for anything else.meta()Model constants for the runtimes (table below).
- Prompts
preprocess, or thetemplateprompt builder (see step 4).
Optional pieces (look at the programs named for examples):
Override / attribute |
Use |
|---|---|
|
Custom sampling: depth transformers ( |
|
Extra per-slot buffers (call |
|
A per-column input mask next to the tokens (e.g. which columns of a row are text or audio). |
|
Only the first |
|
The model has no repetition penalty (or applies its own in |
|
The codec keeps per-request state across chunks; |
|
Chunks with look-ahead ( |
|
Audio input: encoded audio rows that prompts reference through the input mask ( |
|
Input streaming: text arriving while audio is generated ( |
|
The codec can run on a second lane at the same time (no shared scratch state between calls). |
|
The LM step can be captured in segments ( |
|
Kernel fusion with |
meta() keys (LMProgram adds the common ones: batch sizes, page size, codebooks, …):
Key |
Meaning |
|---|---|
|
The model’s identifier; also selects the Rust prompt builder unless |
|
|
|
Per-request token limit, prompt included (required). |
|
Default |
|
Output sample rate in Hz (default 24000). |
|
Frames consecutive audio chunks share (default 0). |
|
Zero frames placed before the first audio frame, so the first chunk is decoded earlier; their samples are trimmed. |
|
See the optional pieces above. |
|
Codecs whose chunks vary in length: samples a chunk advances, and the valid length stored in the last sample of each audio row. |
anything else |
Inputs of the prompt builders (tokenizer paths, special token ids, voices, …). |
3. Registering the model#
Add the names that select the program to _PROGRAMS in vox_serve/model/__init__.py, lower case, as
(package, class). --model is matched exactly first, then as a substring, so list the short name and the
Hugging Face organization prefix:
"my-tts": ("my_tts", "MyTTSProgram"),
"my-org/my-tts": ("my_tts", "MyTTSProgram"),
4. Prompts#
preprocess(text, audio_path, model_kwargs) returns a Prompt (vox_serve/rt/prompt.py): the
prompt’s rows flattened to [rows * n_codebooks] token ids, and with uses_input_mask a mask of the
same length. model_kwargs holds the request’s model-specific options (voice, speaker,
language, ref_text, instruct, x_vector_only_mode); a new option has to be added to both
servers’ /generate form handling (vox_serve/launch.py and rust/src/server.rs). Raise
ValueError for unsupported input, such as audio for a model without audio input: the request then fails
with a 400 response carrying that message. Never wait in preprocess for a resource that running
requests release (a voice slot, feature-pool rows): the Python runtime builds prompts on its scheduler
thread, the thread that releases them. Raise ResourceBusyError (vox_serve/rt/prompt.py) instead;
the request then stays queued and its prompt is built again on a later step. VoiceSlots there keeps an
LRU of reference voices encoded into a program’s slots this way (Chatterbox).
The Python runtime always calls preprocess. The Rust runtime uses the builder named in meta:
pythonCalls
preprocessfor every request, holding the Python interpreter lock while it runs. Start here: it needs no Rust, and it is fast enough unless requests arrive at high rates. It does not support prompts that use the feature pool (audio input) or input streaming.templateFor prompts that are fixed token ids around a tokenized text template, configured in
meta["prompt_template"](Orpheus above). Runs in Rust; nopreprocessneeded.- A Rust builder
rust/src/models/<name>.rsimplements thePromptBuildertrait (preprocess, andtokenize_stream_text/preprocess_streamingfor input streaming), reads its constants frommetain its constructor, and is registered under the model’skindinloadinrust/src/models/mod.rs. Steps that need PyTorch (encoding reference audio, phonemization) can stay in Python as hooks:Hooks::call("name", args)runs the program’shook_<name>(seezonos.rs). The builder must produce exactly the rowspreprocessdoes.tests/test_prompt_parity.pychecks that for text-only requests on CPU, from the model’s recordedmeta: record it once withpython tests/prompt_meta/record.py <name>(on a GPU) and committests/prompt_meta/<name>.json. Text-only prompts should therefore read onlyself.spec_meta(LMProgram.for_promptsbuilds a program without weights for the test). For other requests, compare withvox_serve._rt.build_prompt(meta_json, text, audio_path, model_kwargs_json), or serve with--prompt-builder pythonand compare greedy outputs.
5. Validating#
The tests in tests/ run on CPU (Tests) and check the schedulers and prompt parity,
not models: there is no GPU test suite, so validate a model by serving it.
# Debug model code eagerly (Python exceptions, prints), then with CUDA graphs, in the Python runtime.
vox-serve --runtime python --model my-tts --disable-cuda-graph --port 8000
vox-serve --runtime python --model my-tts --port 8000
curl -X POST localhost:8000/generate -F "text=Hello world" -F "streaming=true" -o out.wav
# The Rust runtime: concurrent requests without HTTP, greedy, audio written to a directory.
vox-serve --runtime rust --model my-tts --bench-requests 4 --bench-out /tmp/my-tts --greedy
python rust/scripts/asr_check.py /tmp/my-tts/bench_*.wav # Whisper transcripts of the outputs
Check that:
the audio is intelligible and complete (the ASR transcript matches the text, the end is not cut off);
streaming and non-streaming requests both work, in both runtimes;
several concurrent requests give the same quality as one (bugs in per-slot state or padding entries only show with batching) and greedy outputs do not depend on the batch;
with a Rust prompt builder, its prompts equal
preprocess’s (python -m pytest tests/test_prompt_parity.pywith the extension built, after recording the model’smeta).
Then measure throughput and latency under load with benchmark/.
6. Documentation#
Add the model to the table in README.md and Supported Models, and add a usage page (docs/usage/<name>.md,
listed in docs/usage/index.rst) with its request options.