Serving#
mstar has two ways to start a server:
mstar serve <model>— a one-command wrapper with sensible per-model defaults (most single-GPU; a few multi-GPU). Best for getting started and single-node runs.mstar-serve --config <yaml>— the low-level entry point that takes an explicit config. Use it for custom layouts, disaggregation, and tensor parallelism.
mstar serve resolves a default config for the model, fills in the plumbing that
mstar-serve needs (socket/upload dirs, a single-node-safe tensor protocol, the HF
cache), and then delegates to it.
mstar serve#
mstar serve <model> [options]
<model> is one of bagel, bagel_cfg_parallel, qwen3_omni, orpheus,
pi05, vjepa2, vjepa2_ac (or pass --config for any other deployment).
Option |
Default |
Description |
|---|---|---|
|
|
Bind address. |
|
|
HTTP port. |
|
|
Require |
|
all visible |
Sets |
|
|
How much the captured forwards’ compiles tune at startup. |
|
model default |
Override the resolved default config with a path to your own YAML. |
|
HF default |
HuggingFace weight cache directory. |
|
|
Tensor transport: |
|
|
ZMQ IPC socket prefix; one per server, so servers on other ports do not share sockets. |
|
|
Temp directory for uploaded media. |
|
|
|
|
off |
Print a per-request profile when each request finishes (see Request profiling). |
|
stdout |
Append the per-request profiles to this file instead of stdout
(implies |
|
off |
Serve Prometheus metrics at |
Each model maps to a default config (override with --config):
Model |
Default config |
GPUs |
|---|---|---|
|
|
1 |
|
|
3 |
|
|
1 |
|
|
2 |
|
|
1 |
|
|
1 |
|
|
1 |
mstar-serve#
mstar-serve --config configs/<model>.yaml [options]
Option |
Default |
Description |
|---|---|---|
|
— |
Path to the YAML config. |
|
|
Bind address / HTTP port. |
|
|
Require |
|
|
|
|
HF default |
HuggingFace weight cache directory. |
|
|
ZMQ IPC socket prefix (shared with conductor/workers). The Dynamo
worker ( |
|
|
Temp directory for uploaded media. |
|
|
Per-request timeout (seconds). |
|
|
Port for the Mooncake RDMA transfer engine. |
|
(auto) |
Network device for TCP tensor transport. |
|
off |
Emit NVTX markers for CUDA profiling. Disabled on XPU and CPU. |
|
off |
Print a per-request profile when each request finishes (see Request profiling). |
|
stdout |
Append the per-request profiles to this file instead of stdout
(implies |
|
off |
Serve Prometheus metrics at |
|
|
|
Note
mstar serve defaults the tensor protocol to SHM (safe on a single node),
whereas mstar-serve defaults to RDMA. On a single node without RDMA, pass
--tensor-comm-protocol SHM to mstar-serve.
API key#
Both commands listen on all interfaces by default and accept requests from anyone who
can reach the port. If no gateway in front of the server authenticates clients, set a
key or bind --host 127.0.0.1; the server logs a warning at startup while no key is
set.
export MSTAR_API_KEY="$(openssl rand -hex 32)"
mstar serve qwen3_omni # or: mstar-serve --config ... [--rust-frontend]
With a key set, every HTTP route except /health and /metrics (see
Metrics) answers 401 unless the request carries
Authorization: Bearer <key>, and WebSocket routes (/v1/realtime,
/generate/ws) refuse the handshake without it. The check is one middleware over the
whole app (FastAPI and the Rust frontend alike), and the comparison takes the same time
however much of the key matches. CORS preflights still get their answer, since browsers
send them without credentials. --api-key overrides the environment variable, but
prefer the variable: a command-line flag is visible to every local user in the process
list. An empty value turns the check off, so --api-key "" disables a key set in the
environment, and so does a variable that expands to nothing (--api-key "$UNSET_VAR").
Clients send the key as their API key: MStarClient(base_url, api_key=...) (it reads
MSTAR_API_KEY when not given one) or OpenAI(base_url=..., api_key=...); see
Using a Server. The standalone Nemotron realtime server
(python -m mstar.model.nemotron_duplex.realtime_api) takes the same flag and
variable.
Under NVIDIA Dynamo, Dynamo’s own frontend serves HTTP and does not forward
Authorization to backends, so the M* worker cannot check a key there: authenticate at
Dynamo’s frontend or a gateway in front of it.
Config files#
A config maps the model’s computation-graph nodes to physical GPU ranks. The keys:
Key |
Meaning |
|---|---|
|
Registry key of the model (see Supported Models). |
|
Maximum sequence length (sizes the KV cache). |
|
List of placements. Each entry assigns |
|
(optional) Per-resource overrides, keyed by the model’s resource names (see below). |
|
(optional) How many requests run at once. Requests past the cap wait in the conductor’s queue, except a live call (open client input), which is refused with a 503. |
|
(optional) The share of its GPU each worker may hold once CUDA-graph capture is done, for workers that share a GPU (see below). Unset, capture plans against whatever is free. |
|
(optional) |
|
(optional, default 512) For a model that takes live client input: how many MiB of files (and append text) one call may send over its whole life, its opening message’s included. A live call’s opening message past it is refused, and an append that would pass it ends the call with a 400; a one-shot request is not counted. It caps a call’s input, not memory: decoded input is larger than its files, and how much of it waits in buffers depends on how fast the model consumes it. |
|
(optional) For a model that takes live client input: per client stream (by
its edge name, e.g. |
|
(optional) Server-init model parameters (see below). |
|
(optional, default true) Whether the server offers per-message deflate on its
WebSocket routes ( |
|
(optional) Per node, |
|
(optional) Requests the server runs after the workers are ready and before it
binds, so no client pays a shape’s first-request cost (torch.compile of a step,
a cold cache). Each entry is a |
|
(optional) Timeout in seconds for the NCCL world group and its parallel subgroups during weight load and warmup; serving keeps PyTorch’s default. Unset keeps PyTorch’s default throughout. Raise it only where weight load, JIT or CUDA-graph capture can exceed that default. |
|
(optional) Small-message all-reduce path of every TP and SP group:
|
|
(optional) Largest message, in KiB, the non-NCCL path takes; larger ones go to NCCL. It is also the size of the buffer each group rendezvouses. Default 512. |
|
(optional) Async scheduling for the parallel nodes: |
|
(optional) Engine knobs for this deployment, by name: |
Node names are model-specific — they are the node names appearing in the model’s graph
walks (e.g. BAGEL’s vit_encoder / vae_encoder / LLM, Orpheus’s LLM /
snac_decoder).
Resource overrides. A model declares the KV caches, attention backends, positions and
samplers that its nodes need. Each declaration has a resource_key. A deployment tunes
them by that name:
resources:
kv_cache:
max_num_pages: 1024 # also: page_size, max_seq_len, cpu_offload_pages,
# prefix_cache, prefix_cache_salt, prefix_cache_tp
talker_attn:
flashinfer_backend: fa2 # also: backend (flashinfer / dense), decode_tensor_cores
vit_attn: # a cacheless (ragged) attention resource
max_segments_per_request: 2 # also: max_tokens_per_request, flashinfer_backend
Use one block per resource. A model with two caches, such as Whisper’s decoder cache and
its encoder context, can therefore size each one separately. If a block names a resource
that the model does not declare, or sets a key that the resource does not accept, loading
fails. A misspelled setting is never silently ignored. A top-level kv_cache: block is
no longer read. It raises an error with a message describing the migration.
prefix_cache: false turns cross-request prefix reuse off for that cache, and
prefix_cache_salt keeps two otherwise identical deployments off each other’s
cached pages. A single request opts out by sending prefix_cache=False, which
travels with its other model_kwargs. Above one rank the cache stays off unless
prefix_cache_tp: true is set as well: rank 0 then decides each hit and the other
ranks adopt it. prefix_cache_tp can’t be combined with cpu_offload_pages, and the
cache’s nodes must run on one TP group, with no replicas and no walks placed on other
ranks. Loading fails otherwise.
On a single rank, a request waits at the head of its pool’s queue until its prompt fits
beside the prompts already admitted, and no request behind it starts first. A cache hit on
a page only the prefix index holds counts as a page the request takes. A request that
can’t fit in its pool at its full output length gets a 400 if it asked for that length,
and otherwise has the model’s default length cut until it fits. The pool
reserves only prompts, so it still has to hold the decode of every request allowed to run
at once, and a decode step that finds no page outside the admitted prompts holds its
requests until they time out.
max_concurrent_requests prevents that. It caps how many requests run at once, and the
pool’s pages divided by the pages one request needs, prompt and output together, is a
safe value. cpu_offload_pages lets a full pool move a request to the host. An offload
frees only the device pages no other running request reads, though, so a request that
shares most of its prompt frees little. Offload helps a pool that runs short now and then.
It doesn’t replace the cap. With a host pool and the prefix cache on, a cached page is
copied to the host once no request holds it, and a later request whose prefix the device
evicted copies it back from there. So the host pool should hold more pages than the device’s
prefix cache.
Single GPU. Everything on rank 0:
model: "bagel"
max_seq_len: 32768
node_groups:
- {node_names: [vit_encoder], ranks: [0]}
- {node_names: [vae_encoder, vae_decoder], ranks: [0]}
- {node_names: [LLM], ranks: [0]}
Several workers on one GPU. A worker is one process per rank, and by default rank
n runs on device n. rank_devices maps ranks onto devices explicitly, so two
node groups can run as separate workers on the same GPU: a heavy autoregressive node then
never waits for a light node’s steps on its worker loop (a TTS talker and its codec):
node_groups:
- {node_names: [Talker], ranks: [0]}
- {node_names: [Codec], ranks: [1]}
rank_devices: {1: 0}
At start-up a worker sizes every CUDA-graph bucket of its nodes with a capture it throws
away, then captures the cheapest buckets across all of them while its largest eager step
still fits beside them; a bucket left out runs eagerly. Workers sharing a GPU see each
other’s memory only as used, so the first to capture can take the other’s room. Set
gpu_memory_fraction (0.45 for two workers, say) to bound what each plans for. It
counts PyTorch allocations, not the CUDA context or NCCL buffers, so leave room for those.
Workers that share a GPU time-slice it by default: one process’s kernels wait for the
other’s. mps: true makes the conductor start a CUDA MPS control daemon of its own
before it spawns the workers, run every worker as its client so their kernels overlap,
and quit the daemon at shutdown. The daemon uses a private pipe directory, so other
processes on the GPU are unaffected. nvidia-cuda-mps-control (part of the NVIDIA
driver) must be on PATH. The *_split.yaml configs for Whisper and Qwen3-ASR set it.
Disaggregation. The same node can live on different GPUs per graph walk — e.g. prefill, decode, and image generation on three GPUs:
node_groups:
- {node_names: [LLM], ranks: [0], graph_walks: [prefill_text, prefill_vit, prefill_vae]}
- {node_names: [LLM], ranks: [1], graph_walks: [decode]}
- {node_names: [LLM], ranks: [2], graph_walks: [image_gen]}
Because placement is config-only, the same model code runs single-GPU or fully
disaggregated. configs/ ships several layouts per model (*_single_gpu,
*_colocated, *_pd_disaggregated, *_cfg_parallel, …).
Tensor parallelism. Shard a node across GPUs with tp_size and that many ranks:
model: "orpheus"
max_seq_len: 131072
node_groups:
- {node_names: [LLM], ranks: [0, 1], tp_size: 2, graph_walks: [prefill, decode]}
- {node_names: [snac_decoder], ranks: [0], graph_walks: [snac_chunk]}
A node must be declared TP-enabled by the model to be eligible for tp_size > 1; the
weight loaders then shard parameters automatically, with no model-code changes. See
Tensor parallelism in the model guide for the model-side
details.
Several TP (or SP) nodes can share GPUs when, on each worker, they are in the same TP and SP
groups, each listed in the model’s tp_enabled_nodes (or sp_enabled_nodes). The ranks
then run every node’s steps in one order. The conductor refuses lockstep nodes on one worker
in different TP or SP groups, and two lockstep nodes on one worker that share a paged KV cache:
rank 0 replicates the page moves it makes on a pool (host offloads and reloads, for example)
per node, riding on one node’s next step, so two nodes on one pool could admit out of step on
a follower. Until rank 0 journals a shared pool’s moves in one stream, place such nodes on
different workers.
Expert parallelism. By default a TP group shards every MoE expert along its
intermediate dimension. An ep_config block on the node group instead gives each rank
whole experts. Attention and dense MLPs stay TP-sharded:
model: "qwen3_omni"
max_seq_len: 32768
node_groups:
- node_names: [Thinker]
ranks: [0, 1]
tp_size: 2
ep_config:
parallel: ep # each rank holds 64 of the 128 experts at full width
- {node_names: [audio_encoder, vision_encoder], ranks: [0]}
- {node_names: [Talker], ranks: [2]}
- {node_names: [Code2Wav], ranks: [2]}
|
Meaning |
|---|---|
|
|
|
The ranks EP spans. Only |
|
Device-assert that every rank routes each token to the same experts. Costs one
all-gather per MoE layer; off by default. Needs |
|
Every N steps, log the per-rank and per-layer expert load. 0 (default) is off.
Needs |
|
Skip CUDA-graph padding rows in the routed experts, TP or EP. Off by default; it gave no throughput gain on Qwen3-Omni at 8 GPUs. |
The number of experts must divide evenly by the group size. A node must be declared
EP-enabled by the model; an ep_config on any other node, an unknown key, or two
per-walk groups giving one node different ep_config blocks fails at start-up. See
configs/qwen3omni_thinker_ep2.yaml.
model_kwargs#
model_kwargs are model parameters fixed at server start (not per request) — they are
baked into the model’s config dataclass and into CUDA-graph captures. For example, the
Pi0.5 DROID variant fixes the action horizon:
model: "pi05"
max_seq_len: 2048
model_kwargs:
action_horizon: 15
node_groups:
- {node_names: [vit_encoder], ranks: [0]}
- {node_names: [LLM], ranks: [0]}
Per-request knobs (temperature, voice, max_output_tokens, …) are sent by the
client instead — see Using a Server.
Restarts and the compile cache#
On a first start Inductor compiles every capture bucket (tuning it at -O1 and
-O2), and FlashInfer JIT-builds its kernels. Both cache the results, and a
start that finds them skips that work. Qwen3.5-0.8B on an H200 took 320 s to start with an
empty cache at the default -O1 (1,294 s at -O2); a warm start skips Inductor’s
compile and tuning. torch’s default cache in /tmp already survives a process restart on
the same host; M*’s default keeps it across a new container or a reboot that clears /tmp.
To keep it, persist these (all under ~/.cache by default):
$XDG_CACHE_HOME/mstar/torchinductor: Inductor’s compiled graphs and autotune picks (TORCHINDUCTOR_CACHE_DIR), and in itstritonsubdirectory Triton’s compiled kernels, Inductor’s and others’ alike (TRITON_CACHE_DIR)~/.cache/flashinfer: FlashInfer’s JIT builds (it followsFLASHINFER_WORKSPACE_BASE, default home, not$XDG_CACHE_HOME)$HF_HOME: the weights ($XDG_CACHE_HOME/huggingfaceby default)
$XDG_CACHE_HOME defaults to ~/.cache. M* sets both variables unless you export them,
and TRITON_CACHE_DIR follows an exported TORCHINDUCTOR_CACHE_DIR.
In a container, mount these from a volume rather than only ~/.cache/huggingface.
Inductor keys compiled graphs and -O1’s picks on the torch build. On CUDA and ROCm,
compiled graphs (and -O2’s GEMM timings) also key on the Triton build, the device (its
name on CUDA, its gfx arch on ROCm) and the CUDA or HIP version; on XPU, on none of those.
-O1’s picks key on the kernel source, which names the compute capability and SM count but
not the GPU model, so give each GPU model (or each ROCm card sharing an arch) its own cache
if one home serves several. Neither key covers the ptxas that built a kernel or the driver: after changing
either, delete the cache or set torch’s TORCH_COMPILE_CACHE_KEY_TAG to a new value.
The default trades torch’s node-local /tmp for home. Nothing prunes the cache, and every
upgrade, -O level and changed graph adds to it; it is safe to delete, which costs only the
next start’s compile. On a quota-limited or network-mounted home (NFS), or a volume that
concurrent jobs on several hosts share, export TORCHINDUCTOR_CACHE_DIR to a node-local
path and copy a warm cache in at job start.
Tensor transport#
Workers route tensors directly to one another using one of three transports, selected with
--tensor-comm-protocol:
SHM— shared memory; the safe default for single-node deployments.TCP— works anywhere; used for some multi-node setups.RDMA— lowest latency for multi-GPU / multi-node, via the Mooncake transfer engine (requires RDMA-capable networking).
TCP and RDMA run Mooncake’s transfer engine, which does not authenticate its peers.
Each worker’s engine picks its handshake and data ports at startup, so restrict by host: only
the deployment’s own hosts should reach the workers. The model extras require
mooncake-transfer-engine 0.3.13.post1
or newer: earlier than 0.3.12.post1, any host that reaches the TCP data port can read or
write the worker’s memory (CVE-2026-103764), and a worker warns at startup if it finds such a
version. Two denial-of-service issues on the handshake port (CVE-2026-103760 and
CVE-2026-103761) are still open in 0.3.13.post1.
Request profiling#
Pass --log-stats (to either mstar serve or mstar-serve) to print a per-request
profile when each request finishes. Add --log-stats-file <path> to append the profiles
to a file instead of stdout (it implies --log-stats):
mstar serve bagel --log-stats
mstar-serve --config configs/orpheus_colocated.yaml --log-stats-file stats.txt
The report has four sections (any that has no data is omitted):
============================================================
Request profile: 3f9c1e2a-…
============================================================
Inputs:
text x1 72 B
image x1 1.1 MiB
Outputs:
image x1 1.1 MiB
------------------------------------------------------------
Timeline:
recv → preprocess done 62.6 ms
preprocess done → conductor ingest 2.3 ms
conductor ingest → first chunk 133.7 ms
first chunk → last chunk 683.3 ms
last chunk → conductor done 1.5 ms
conductor done → finish 3.1 ms
total 886.5 ms
------------------------------------------------------------
Graph timings (CPU, ms) — total over request (avg per exec):
all fwd pre post*
LLM
decode n=588 3277.9 ( 5.57) 2858.0 ( 4.86) 243.0 ( 0.41) 176.9 ( 0.30)
prefill n=1 8.78 ( 8.78) 4.24 ( 4.24) 3.47 ( 3.47) 1.06 ( 1.06)
* post overlaps the next step / another in-flight batch under
speculative scheduling, so it is not necessarily additive.
------------------------------------------------------------
Tensor transfer size / transport-time / count:
rx (received over the wire), by source → dest:
worker_0 → worker_1
hidden 106.7 MiB 78.7 ms (x49)
tx (registered/written for send), by source:
worker_0
hidden 106.7 MiB 0.90 ms (x49)
============================================================
Inputs / Outputs — per-modality count and byte size. Inputs are the raw client-supplied bytes (uploaded file sizes on disk, UTF-8 length of the prompt), not the much larger decoded tensors, so they line up with what was sent over the wire.
Timeline — wall-clock stage boundaries for the request as it flows
api server → conductor → workers → api server. Stages are ordered by their actual
timestamp, so a stage is never negative even when two checkpoints race (e.g. the API
server’s last chunk and the conductor’s done can arrive in either order). All
timestamps are time.perf_counter(); they are comparable across the api-server,
conductor, and worker processes because those run on the same host (perf_counter is
CLOCK_MONOTONIC, which is boot-relative and shared).
This implementation will be updated when multi-node support is added.
Graph timings — CPU time per (node, graph_walk), accumulated over the request.
Each cell shows the total over the request and, in parentheses, the average per
execution (n is the execution count, so e.g. one entry covers all 588 decode
steps). The columns:
all— the full node execution (=pre+fwd+post).fwd— the GPU forward region. Because execution is asynchronous and not synced (a sync would serialize the pipeline), this measures the CPU launch/enqueue span of the whole batch, not per-request GPU time.pre— input preparation / preprocessing before the launch.post— CPU postprocess after the forward. Markedpost*because under speculative scheduling it can overlap the next step’s forward or another in-flight batch, so it is not necessarily additive.
Tensor transfer — bytes moved between entities via the tensor transport
(above). rx (received) is grouped by source → dest; tx
(sent) is grouped by source only — the sender registers/writes data without knowing a
priori which worker will read it (and may register data that is never sent), so there is no
per-destination breakdown. size is total bytes, the time is transport cost (the
RDMA-read or SHM file-read time on rx; the registration / serialize-and-write time on
tx), and count is the number of tensors.
Note
Profiling is gated end-to-end on --log-stats: when it is off, the workers,
conductor, and data worker skip the timing/transfer bookkeeping entirely.
Metrics#
Pass --enable-metrics (to either mstar serve or mstar-serve) to serve
Prometheus metrics at GET /metrics. It needs the metrics extra
(pip install 'mstar[metrics]'); without it the server stops at startup with that
hint. Off by default, and /metrics answers 404 then. Like /health, it needs no
API key even when one is set, the usual convention for scrape endpoints. It exposes
request counts, latencies and the model name (never prompts or outputs); if that
matters for a deployment, keep the port on an internal network. With
--rust-frontend, mstar-server serves the same text: it asks the API server for
it over the bridge, so both frontends show the same names, and it answers 503 if the
bridge does not reply within two seconds, as /health does.
Metrics reference documents every family in one place, with a Prometheus and Grafana setup.
Requests are counted when they leave the API server, from the first request after the
warmup requests. Every family has a model_name label (the config’s model);
output_modalities is the request’s output modalities, sorted and comma-joined
(e.g. audio,text).
mstar_requests_total{model_name, output_modalities, outcome}: counter.outcomeissuccess(the result went to the HTTP layer),error(the request failed in the engine, preprocessing or result delivery),abort(the client went away, or its finished result was dropped unread) ortimeout(--timeoutexpired). Requests refused before they were submitted (a 400 for a bad form, a 409 for arequest_idin flight) are not counted here but inmstar_http_refusals_total.mstar_requests_in_flight{model_name}: gauge, requests the API server holds (submitted and not yet returned, failed or aborted).mstar_e2e_request_latency_seconds{model_name, output_modalities}: histogram over successful requests, from submission (after the request body was read) to the result leaving the API server.mstar_time_to_first_output_seconds{model_name, output_modalities}: histogram over successful requests, from submission to the first result chunk of any modality reaching the API server. It is measured on the server, so it does not include the network to the client, and a non-streaming client sees nothing until the end.mstar_http_refusals_total{model_name, route, status}: counter, HTTP requests the frontend answered with a 4xx status without submitting them to the API server:401(missing or wrong API key),413(body overMSTAR_MAX_BODY_MB),422(body that fails validation),400(a bad form field or request),409(arequest_idin flight),404(unknown route, or a model without the endpoint),405(wrong method), plus503when the Rust frontend’sMSTAR_MAX_CONCURRENT_REQUESTScap turns a request away.routeis the route’s path as declared (/v1/chat/completions),unmatchedwhen no route matches. A request that was submitted and then failed counts once, as anerrorinmstar_requests_total, never here. WebSocket handshakes are not counted. With--rust-frontend,mstar-serverkeeps these counts and sends them with each/metricsrequest, so the API server exports them under the same name.mstar_request_queue_seconds{model_name, output_modalities}: histogram over successful requests, from the conductor taking the request in to admitting it for dispatch. Zero unless the request queued atmax_concurrent_requests.
While metrics are on, the conductor sends the API server a status message every second (nothing when they are off). These gauges show its latest one, so they can be up to a second old:
mstar_conductor_requests{model_name, state}: requests the conductor holds.stateiswaiting(queued atmax_concurrent_requests, not dispatched yet),running(dispatched to workers, not finished) ordraining(completed, aborted or failed, and waiting for every reader to release its outputs before the workers free its state; it still counts towardmax_concurrent_requests).mstar_component_in_flight{model_name, component}: per graph node (thenode_namesof the config’snode_groups), running requests with a pass on that node that the conductor dispatched and has not heard back from. The conductor sees whole passes, so a node running a request’s decode loop counts it once for the loop.mstar_conductor_status_age_seconds{model_name}: seconds since the API server received the last status message, or since metrics were enabled before the first one. More than a few seconds means the conductor stopped reporting.
The engine workers also send the conductor their own metrics every second, which it
adds to its status message, so these can be up to two seconds old. component is the
graph node; a node with replicas on several workers shows their sum, and the ranks of a
tensor or sequence parallel instance count once (the instance’s first rank reports).
The histograms and counters count from the first request after the warmup requests, as
the API server’s do (the gauges show the current state), so read them with rate()
or increase().
mstar_kv_pages_used{model_name, component, resource},mstar_kv_pages_total{model_name, component, resource}: pages of each paged KV cache (resourceis its key in the model, since a node can have several, e.g. Whisper’s decoder self and cross attention caches). Used counts every page off the free list, including the pages the prefix cache keeps; a cache two nodes of one worker share shows under each node.mstar_kv_pages_cached{model_name, component, resource}: pages the prefix cache names. Some may also be in use by running requests; the cache frees the others when a request needs the room.mstar_step_seconds{model_name, component, graph_walk}: histogram over engine steps that ran a forward. A step counts from when the worker hands it to its GPU thread, or from the end of the previous step if that was later, to the end of its postprocess (outputs on the host, stop check, routing). Overlapping steps are not counted twice: in steady state this is the interval between step completions, andrate(..._sum)is the share of time the worker had a step running.mstar_batch_size{model_name, component, graph_walk}: histogram, rows the step’s forward ran.mstar_prefix_cache_query_tokens_total{model_name, component},mstar_prefix_cache_hit_tokens_total{model_name, component}: counters, prompt tokens looked up in the prefix cache (once per request, its whole keyed prompt) and tokens found there. Only models that key their prompts for prefix reuse (BAGEL, Orpheus, Qwen3.5 and Qwen3-Omni’s Thinker) count, and above one rank only withprefix_cache_tpset. Qwen3.5’s hit counts the KV cache’s tokens before its recurrent pool cuts the hit to a snapshot.mstar_component_pending_steps{model_name, component}: gauge, requests whose next step on the node is ready (its inputs arrived) and not running: waiting for room in a step or for the worker to pick the node.
Tensor transfers between graph nodes (tensor transport) are counted by the reader, once per edge read: the tensors of one graph edge that one worker read from one producer. The workers send them with the engine metrics above, and the API server adds the results its data worker read:
mstar_transfer_bytes_total{model_name, src, dst, transport}: counter, tensor bytes read over the edge.mstar_transfers_total{model_name, src, dst, transport}: counter, tensors read over the edge.mstar_transfer_seconds{model_name, src, dst, transport}: histogram, one observation per edge read: the reader’s time to read that edge’s tensors, which is the SHM file read and copy, the copy out of the SHM arena, or the RDMA or TCP read. A GPU reader of the arena only issues its host-to-device copies in that time; they finish asynchronously. The producer’s own work (serializing, staging into the arena, registering memory) is not included; it is thetxtime of request profiling.
src is the graph node that produced the tensors and dst the node that reads them.
An edge the conductor joined from several producers (one input name produced in several
walks or nodes) is one read per producing node, with its read time split between them by
bytes.
api_server stands for the request’s inputs (as src) and its results (as dst),
which the API server’s data worker sends and reads. transport is the protocol:
SHM (per-tensor files or the arena, see MSTAR_SHM_ARENA), TCP or RDMA. The
Python and Rust runtimes and communicators (MSTAR_RUST_GRAPH, MSTAR_RUST_ZMQ)
report the same series. A tensor already on the reader (one it made, or one it read once
for two of its nodes) moves nothing and is not counted. With tensor parallelism every
rank counts its own read, of its slice or of the whole tensor when the edge is
replicated, so an edge two ranks read is two reads. Like the data worker, the workers
count from the first request after the warmup requests.
Where a successful request’s time went, from the timestamps every request already
carries (same model_name and output_modalities labels; the conductor’s
timestamps are compared with the API server’s, which assumes both run on one host, as
they do today):
mstar_request_preprocess_seconds: histogram, from submission to the data worker handing the preprocessed request to the conductor, including its wait in the data worker’s queue.mstar_request_engine_seconds: histogram, from the conductor dispatching the request to the workers (after any admission wait) to the conductor finishing it.mstar_request_delivery_seconds: histogram, from the conductor finishing the request to the result leaving the API server (the last chunks’ postprocessing and transfer, and the client handler picking it up).
Output, per output modality (modality is the chunk’s modality, e.g. audio):
mstar_output_chunks_total{model_name, modality}andmstar_output_bytes_total{model_name, modality}: counters of the result chunks (and their bytes) that reached the API server, for every request whatever its outcome; error chunks are not counted.mstar_output_audio_seconds_total{model_name}: counter, seconds of audio in those chunks (16-bit PCM at the chunk’s sample rate and channel count).mstar_time_to_first_chunk_seconds{model_name, modality}: histogram over successful requests, from submission to the first chunk of each output modality reaching the API server (time to first audio, first image, first video frame).
The data worker (a thread in the API server that loads each request’s media and runs
the model’s prompt processing, preprocessing one request at a time) reports its own
work.
input_modalities is the request’s input modalities, sorted and comma-joined like
output_modalities:
mstar_data_worker_preprocess_seconds{model_name, input_modalities}: histogram over requests whose preprocessing finished, from the data worker taking the request off its queue to handing it to the conductor. Unlikemstar_request_preprocess_secondsit leaves out the wait in the queue, and it counts requests whatever their outcome later.mstar_data_worker_preprocess_failures_total{model_name, input_modalities}: counter, requests whose preprocessing failed in the data worker, each once, whatever raised: a file count or media length over its limit, a file that cannot be decoded, the model’s prompt processing, or handing the inputs on. The client gets the error (a 400 for a bad input), so the request also ends asoutcome="error"inmstar_requests_totalunless its client had already gone. That counter counts failures from every stage by output modalities; this one only the data worker’s preprocessing, by input modalities (failures turning results into output chunks are not counted here).mstar_upload_size_bytes{model_name, modality}: histogram, the size on disk of each image, audio or video file a request carried to the data worker (modalityisimage,audioorvideo), whether the request succeeded or failed. Every route writes its media to a file first: an upload as received, inline media (adata:URL, base64 files on/generate/ws) after base64 decoding. The realtime route and long transcriptions send WAV files they write from the decoded audio, so those count the WAV sizes. Requests refused before they were submitted (an oversized body, a bad form) are not counted.mstar_data_worker_queue_depth{model_name}: gauge, read at scrape time, requests submitted and not yet taken up by the data worker (the one it is working on is not counted). A request whose client went away still counts until the data worker takes it up.
mstar serve kokoro --enable-metrics
curl -s localhost:8000/metrics | grep '^mstar_'