Adding a New Model#
This page describes everything you must implement to add a new model to mstar. When
you finish, the conductor can schedule your model, workers can execute it on GPU, and you
can launch it with mstar-serve.
Overview#
A model in mstar has several separate responsibilities:
The
Modelclass (mstar/model/base.py) is the interface that the rest of the system calls. It tokenizes prompts, declares the computation graph, declares the resources each node needs, builds forward-pass arguments, and post-processes outputs. It contains no GPU compute.Submodules (
NodeSubmoduleinmstar/model/submodule_base.py) are thetorch.nn.Modules that perform the compute. Each graph node maps to one submodule.Resources (
mstar/engine/resources/) hold the state that a node’s compute uses: a paged KV cache, the attention planned over that cache, position embeddings, and a sampler. The model declares its resources inget_node_resources(). The engine then builds one object per declaration and binds it into the submodule and its layers. A node that declares no resources was called a “stateless” node in earlier versions.The engine (
mstar/engine/engine.py) is a single class that runs every node. It compiles forwards, captures CUDA graphs, batches requests, and runs each step’s resource lifecycle (admit, plan, forward, commit). You never write an engine. Earlier versions required you to choose an engine type per node. That is no longer true. A node’s capabilities now follow from the resources it declares.The graph (
mstar/graph/base.py) declares what runs in what order: nodes, edges between them, and loops. All of a model’s work forms one large computation graph. Each named “graph walk” (for exampleprefillordecode) is one path through that graph. In code, however, you declare each walk as a separate standalone graph. A node that appears in several walks is referenced by name in each walk. The submodule behind that name, and its resources, are shared across all of them.The config YAML (
configs/) maps graph nodes to physical GPU ranks throughnode_groups. Disaggregation is configured here. The same model code runs on one GPU or on many, and only the config changes.
One vocabulary note for this page. A tensor bundle routed between nodes is a
NameToTensorList, which is dict[str, list[torch.Tensor]]
(mstar/communication/tensors.py). It maps an edge name to a list of tensors. The list
usually has length 1.
The diagram below shows the request flow at the conductor and model level. Its granularity is one graph walk. The conductor is notified when a walk completes, and only then asks the model what to do next. Everything that happens inside a walk is described in later steps and is omitted here:
process_prompt() # text/media -> initial tensors
│
▼
get_initial_forward_pass_args() # seed the first graph walk (e.g. prefill)
│
▼ (conductor walks the graph, the engine runs each node)
get_partition_forward_pass_args() # asked after each graph walk completes:
# what's next? done?
│
▼
postprocess() # model output tensor -> bytes for the client
What you will create#
A typical model lives in its own package under mstar/model/<your_model>/:
mstar/model/<your_model>/
├── __init__.py
├── config.py # a @dataclass with architecture + generation params
├── <your_model>_model.py # the Model subclass (the contract)
├── submodules.py # NodeSubmodule subclasses (the compute wrappers)
└── components/ # the actual nn.Modules (attention, decoder, etc.)
Plus a handful of things outside that package:
an entry in
mstar/model/registry.pyso the model is discoverable,a config YAML in
configs/mapping nodes to ranks,an entry in
DEFAULT_CONFIGS(mstar/cli/main.py) somstar serveknows the name,a row in
docs/models.rst, andan OpenAI adapter, only if the model needs OpenAI-compatible routes.
Step 1 covers all of them.
Step 1 — Register the model#
Open mstar/model/registry.py and add your model to MODEL_REGISTRY (and, if it
loads weights from Hugging Face, to HF_MODELS). The dict key is the string you put
under model: in a config YAML.
The value is a (module_path, class_name) tuple, not an imported class: the module is
imported lazily through import_module when the model is actually used, which keeps
mstar.model.registry cheap to import. A direct class import here would pull every
model’s dependencies into every process.
MODEL_REGISTRY: dict[str, tuple[str, str]] = {
# ...
"your_model": ("mstar.model.your_model.your_model_model", "YourModel"),
}
HF_MODELS: dict[str, dict] = {
# ...
"your_model": {"model_path_hf": "org/your-model-id"},
}
There is no plugin scan, so the registry is the only place the class is discovered — but it is not the only wiring step. A model also needs:
DEFAULT_CONFIGSinmstar/cli/main.py, mapping the name to its default config file. Without it,mstar serve <your_model>exits witherror: unknown modelbefore anything loads.a row in
docs/models.rst, which is the model list users read.an
OpenAIAdaptersubclass inADAPTER_REGISTRY(mstar/api_server/openai/adapters.py), only if the model maps onto OpenAI semantics such aschat.completions,audio.speechorimages.generate. A model reached only throughPOST /generateneeds none.the client-example tuples further down
mstar/cli/main.py, somstar serveprints a usage snippet. Cosmetic, but user-visible.
test/modular/test_model_registration.py enforces the registry, CLI and docs entries, so
CI fails rather than shipping a model nobody can reach.
Step 2 — Implement the Model class#
Subclass mstar.model.base.Model and implement its abstract methods. The
constructor receives model_path_hf (from HF_MODELS) and any **kwargs. It
normally loads the tokenizer and stores a config dataclass. Do not load weights in the
constructor. Load them in get_submodule, so that the conductor process never
allocates GPU memory.
You must implement these abstract methods:
get_node_resources(self) -> list[NodeResourceSpec]Declare every resource the engine builds for this model, and which nodes share each one. This is the largest single part of a model, so it has its own section. See Step 2a — Declare your resources below.
get_graph_walk_graphs(self) -> dict[str, GraphSection]Return
{walk_name: graph}. See Step 3 — Declare the computation graph.process_prompt(self, prompt, input_modalities, output_modalities, tensors=None, prompt_parts=None, **kwargs) -> NameToTensorListTokenize the prompt and produce the initial request tensors, for example
{"text_inputs": [token_ids]}. This method runs in the API-server data worker, after raw media tensors are loaded. It can therefore readtensors(for exampleimage_inputs,audio_inputsorvideo_inputs) to compute derived tensors such aspixel_values. The returned dict is merged into the request’s tensors.input_modalitiesis the request layout: one entry per prompt element in the order written, so["image", "text", "image"]is a distinct request from["image", "image", "text"].prompt_partscarries the same sequence with the text attached, and isNonefor entrypoints that submit files and text separately.mstar.model.multimodalturns either into the prefill plan thatprocess_promptand the schedule builder both work from.get_initial_forward_pass_args(self, partition_name, input_modalities, output_modalities, input_signals, model_kwargs=None) -> ForwardPassArgsBuild the first
mstar.model.base.ForwardPassArgsfor a partition. It names the graph walk to start on and the input edges that feed it.get_partition_forward_pass_args(self, partition_name, partition_metadata, persist_signals, incoming_connections=None) -> ForwardPassArgsThe conductor calls this after each graph walk completes. It returns the next walk, the inputs for that walk, and whether the request is finished (
request_done=True). A simple prefill-decode model setsis_prefillto false once, then repeats the decode walk until EOS.get_submodule(self, node_name, device="cpu", tp_group=None, autocast_dtype=None, sp_group=None, moe_parallel=None) -> NodeSubmodule | NoneBuild and return the
NodeSubmodulefornode_name, and cache the result. Load weights here, ondevice. ReturnNonefor dummy mode.tp_groupandsp_groupare the node’s tensor-parallel and sequence-parallel communicators when the node is sharded. Passtp_groupto the parallel-linear constructors. See Step 6.moe_parallelis the node group’sep_config, parsed into aMoeParallelConfig. It is passed only to an EP-enabled node whose group sets one, so a model without MoE layers never receives it. See Expert parallelism (MoE).autocast_dtypeis the dtype in which the node’s parameters must be allocated. If you build the module on themetadevice, cast it to this dtype before callingto_empty(device). Casting afterto_emptyallocates every parameter in float32 first, which doubles the peak VRAM during loading.
Note
model_kwargs reaches your model from clients through the OpenAI routes’
extra_body passthrough. The Dynamo bridge
(mstar/integrations/dynamo/bridges.py) strips OpenAI-standard fields no
model consumes (_STRIP_KEYS) before that passthrough runs. If your model
starts reading such a field from model_kwargs — the way ignore_eos is
read — also delete the key from _STRIP_KEYS, or requests arriving through
the Dynamo frontend will silently lose it.
Note
model_kwargs cross the control plane to the conductor as plain data, so a
value that process_prompt moves onto a tensor edge must not travel there
too. List those kwargs in PREPROCESS_ONLY_KWARGS: the data worker keeps the
originals for postprocess and leaves them out of the conductor’s copy. Pi0.5
lists robot_state, and Cosmos3 lists action (it passes only the shape on as
action_shape metadata). A tensor left in model_kwargs fails at send
with an error that names the kwarg.
Note
The metadata process_prompt returns is laid over the request’s own
model_kwargs, so a key the model reads back there (a prompt layout, a
stream lead) must be set in every mode and listed in INTERNAL_KWARGS: the
data worker drops a client’s value for it before process_prompt. Qwen3-TTS
lists its prompt layout, speaker, language, clip digest and codec stream lead.
Note
The conductor merges each walk’s ForwardPassArgs.step_metadata into the
request’s metadata.kwargs, so a request fact stored under a key that a
later walk’s step metadata also uses is overwritten by that walk’s value. Keep
such facts under their own key: LTX-2.5 nests the request’s geometry under
kwargs["request"], since its stage-1 walk sends a half-size height.
The following methods have defaults and are optional to override:
text_output, postprocess and postprocess_inline (described next),
get_request_resource_configs (described below), get_sampling_config,
get_max_output_tokens, get_autocast_dtype, load_image, load_audio,
load_video, and the partition methods described at the end of this page.
text_output(self) -> TextOutput | NoneDeclare how text output (token ids) becomes bytes: a detokenizer, and the ids that end a reply, which are dropped unless the request sets
ignore_eos.postprocessandpostprocess_inlinederive their text paths from it. A text chunk may end inside a character, since the API server joins a request’s text chunks before decoding them, so a byte-level BPE model should declaremstar.model.utils.TextChunkDetokenizer(orByteLevelDetokenizer), which emits each token’s raw bytes, rather thantokenizer.decode([id]), which turns a split character into U+FFFD.postprocess(self, output, modality, request_kwargs=None) -> bytesEncode a finished output tensor to bytes for the client:
utf-8for text, PNG for images, raw PCM for audio, and so on. Override it for any modality other than text, and hand text tosuper().postprocess.postprocess_inline(self, values, modality, request_kwargs) -> list[bytes]postprocessfor one decode step’s inline values (one sampled token id per request), called once per step. Withtext_outputdeclared, each id maps to its memoized bytes; otherwise each value goes throughpostprocessas a one-element tensor.
Model.nodes is a read-only property. It returns the sorted set of node names that
appear in any graph walk.
Per-request resource parameters#
A resource spec is fixed at load time and is shared by every request. Parameters that differ per request, such as sampling parameters and the set of cache labels a request uses, come from a separate method:
get_request_resource_configs(
self, partition_fwd_args: dict[str, ForwardPassArgs],
model_kwargs: dict | None = None,
) -> dict[str, ResourceReqConfig]
The returned dict is keyed by resource_key. The conductor calls this method once per
request, and the engine passes each config to its resource when the request is ingested.
There are two ResourceReqConfig subclasses:
SamplingReqConfigholdstemperature,top_k,top_p,repetition_penalty,min_pandignore_eos. The conductor fills in the per-request seed.min_pfollows the HF processor order (after the penalty and temperature, before top-k/top-p) and needsenable_min_p=Trueon the node’sSamplerSpec, which adds the filter to that node’s captured sampler only. Further knobs, each needing aSamplerSpeccapability (CUDA only):repetition_window(max_repetition_window): the penalty counts a token’s occurrences in the last N generated tokens,repetition_penalty ** n.min_tokens(min_tokens_stop_ids): those ids are barred untilmin_tokenstokens exist; applied after top-k/top-p.top_p_first,top_p_min_keep(enable_top_p_first): HF’s order (top-p, then top-k) instead of FlashInfer’s, and HF’smin_tokens_to_keep.
SamplerStep(apply_filters=False)samples a step with top-k/top-p off (e.g. a TTS’s first code); declare it the same way on every step of a walk.KVReqConfigholdsneeded_labels,needed_labels_per_nodeandneeded_labels_per_node_walk. These name the cache streams that the request will actually read. In a PD-disaggregated deployment, a KV transfer then copies only those streams.
Orpheus shows the simple case. It has one sampler, and reads the parameters from
model_kwargs:
def get_request_resource_configs(self, partition_fwd_args, model_kwargs=None):
model_kwargs = model_kwargs or {}
keys = ["temperature", "top_p", "repetition_penalty", "ignore_eos"]
return {
SAMPLER: SamplingReqConfig(
**{k: model_kwargs.get(k, getattr(self.config, k)) for k in keys}
)
}
BAGEL (bagel_model.py) returns both config types. A BAGEL request uses
classifier-free guidance or does not, and that choice determines which cache labels the
request reads.
get_sampling_config(node_name, model_kwargs) still exists as a helper for assembling
sampling parameters. The engine no longer reads it directly. Pass its result into a
SamplingReqConfig here.
Warning
Never pass sampling parameters into a captured forward as Python scalars. Their values
are recorded into the captured kernel launch, and every replay then reuses the values
from capture time. No error is raised. Parameters that reach the sampler resource
through SamplingReqConfig are stored in buffers whose addresses do not change
across replays, so they stay per-request and remain safe under CUDA graphs.
Paged KV layouts#
A model with a paged KV pool states, for every request, what its walks write into each
label of the pool. The KV manager turns that layout into the request’s footprint, the most
pages each label can hold, before the request runs. A model declares its labels once and
returns each request’s layout from process_prompt.
kv_labels(self) -> dict[str, dict[str, KVLabel]]Return
{resource_key: {label: KVLabel}}for every label of every paged pool. Amstar.model.base.KVLabelhas two fields:growth:(walk, loop)pairs, each a walk that keeps writing after admission, bounded by the named loop. Only a named loop can bound growth, since an unnamed one is renamed in every process.keys: amstar.model.base.PrefixKeys, orNone. See Cross-request prefix reuse below.
A label written only by spans and scratch, such as a guidance branch, is
KVLabel().
process_prompt returns, under kv_layout in its metadata, a
mstar.model.base.LabelLayout for every label the request opens: the labels its
KVReqConfig can name on any node and walk, main when it names none. A layout may be
empty where an opened label is never written. It may also name labels the request opens
only on another deployment, as an unguided BAGEL generation does for a CFG-parallel one.
A layout has five fields:
spans: the committed writes known at intake, in walk order. Amstar.model.base.Spanhas akind:"ids"for token ids from the tensorsourcenames,"digest"for KV made from an uploaded file (sourceis(modality, index)), and"rows"for KV made from anything else, such as another node’s outputs.lengthis the slots the write fills,advancehow far it moves the position counter. A span’sscratchis the most one non-committing step writes before the span commits into those slots, as a window’s denoise steps do.growth:(loop, slots)pairs,slotsper iteration ofloop.scratch: the most KV slots one non-committing step writes past the committed length.retention: theRetentionPolicythe label’s committing steps declare.keyed_spans: how many leading spans the label’s keys cover, by default every span before the firstrowsspan.
The footprint adds the spans, the growth at each loop’s iterations and the scratch, and
rounds up to whole pages per label. A span’s own scratch counts only with the spans before
it, so a window’s denoise steps and its commit count once. Under a RetentionPolicy it
takes the window’s peak instead. KVReqConfig.validate refuses a request whose footprint
is bigger than its pool with a 400 at intake, and KVReqConfig.loop_room cuts the default
length of a request that asks for none until it fits. With every loop at 0 iterations, the
same sum is the prompt a request reserves when its pool admits it.
prompt_spans(plan, texts, text_walk, item_spans) builds the common case: a prompt’s
text and attachments in prefill_plan order. Qwen3.5 lays out its one label with it:
layout = LabelLayout(
spans=tuple(prompt_spans(prefill_plan(parts), segments, walk, image)),
growth=(("decode_loop", 1),),
)
return ProcessPromptOutput(result, {"kv_layout": {KV_CACHE: {"main": layout}}})
get_loop_caps(self, **model_kwargs) -> dict[str, int]The iterations each named loop may run for this request. The conductor refuses a request whose cap is past its loop’s graph
max_iters, or whose layout at its caps holds more positions than its pool’smax_seq_len, with a 400. A request that sets no length has the defaultmax_output_tokenscut to the context its prompt leaves, and a stop check reads that length fromrequest_info.max_tokens, which carries the cut. A model that reads a length from another key lists it inLENGTH_KWARGS. Speculation never runs past a cap. A model whose stop check ends a loop at a count returns that count, so the cap and the stop check end the loop on the same iteration;test/modular/test_model_kv_layouts.pychecks this for every model. Growth on an uncapped loop is counted to its graphmax_iters.
Where process_prompt computes a length that a walk computes another way, such as a
media item’s rows, the walk asserts the two agree: BAGEL’s ViT walk against
vit_patches, Qwen3-Omni’s vision encoder against the processor’s grid.
Loading fails in three cases, and a request fails in two:
A paged pool has no labels. The message is
<Model> declares no labels for its paged pool '<key>'; add them to `kv_labels`, and return each request's layout for them from `process_prompt`. A ring pool needs none.A label’s growth or keys name a walk the model never runs. The message is
<Model> declares '<key>'/'<label>' over ['<walk>'], which it never runs; its walks are [...].A label grows on a loop its walk doesn’t run. The message is
<Model> declares '<key>'/'<label>' growing on '<loop>', which '<walk>' doesn't run; its loops are [...].A request’s layout leaves out a label the request opens, or lays out one the model doesn’t declare. The request fails with a 500,
<Model> returned no kv_layout for '<key>'/'<label>', which the request opens, or<Model> returned a kv_layout for '<key>'/'<label>', which it does not declare.
A request that takes a page past its footprint logs once per label, with the layout that
under-counted. A request is held only to the labels it opens, and to no pages on a label
it doesn’t open. A page there logs the same line, reported apart from a real overrun on
that label so neither hides the other. Under MSTAR_KV_DEBUG_ASSERTS=1 each commit is also
checked against the layout: the walk that wrote each span, growth past its bound, scratch
past its declaration and the retention a step declares. At removal, a label left a page
or more under its footprint logs the most pages it held.
Cross-request prefix reuse#
Two requests that begin with the same tokens fill the same KV pages, and the second can
attend the pages the first filled instead of recomputing them. A model opts a label in by
giving it keys, and names its checkpoint. A model with no keyed label is never cached.
The deployment switch and the per-request opt-out are described under Resource
overrides in Serving.
PrefixKeys(tensor, walks, growth=False)tensornames the request tensor whose entries the layout’s ids spans are keyed by, asprocess_promptreturns it.walks: only a write from one of these walks keeps the keys going. A write from another walk ends them, and the rest of the label is computed, never served.growth: the ids the label’s growth walk samples extend the keys, so a later request that re-sends the reply matches it too.
The keys cover the label’s first
keyed_spansspans. An ids span is keyed by its token ids, a digest span by its file.BAGEL keys its text and ViT walks and the decode that follows them:
return {"kv": {"main": KVLabel( growth=((DECODE, "decode_loop"),), keys=PrefixKeys("text_inputs", ("prefill_text", "prefill_vit"), growth=True), ), ...}}
Orpheus keys its prompt walk:
return {KV_CACHE: {"main": KVLabel( growth=(("decode", "decode_loop"),), keys=PrefixKeys("text_inputs", ("prefill",)), )}}
checkpoint_path(self) -> str | NoneReturn the local directory that holds the weights and
config.json. The cache hashes the checkpoint’s safetensors index and config into the root of every key, so naming the checkpoint is what keeps two builds off each other’s pages. The default returnsNone, which leaves the weights out of the root. A model without a checkpoint therefore needsprefix_cache_saltset to use the cache at all. Without one, the engine keeps the cache shut and logs why.
A recurrent layer can’t skip tokens: its state after a token is all it keeps of the tokens
before it. On a node with a recurrent state pool, a hit is cut to the longest snapshot of
that state the pool holds under it. Name the KV cache in the pool’s config
(RecurrentStateConfig(kv_cache=...)), and give the deployment prefix_slots under
the pool’s resources: key. Each snapshot slot costs as much as a request slot.
A prefill keeps the state at its last page boundary before its end, since a resend’s KV hit always leaves the last page to recompute, and at its KV hit’s last page boundary when that is past what it restored. A shared prefix is then served from the third request that shares it. A step longer than the largest captured bucket checkpoints less often, so that its checkpoints fit the captured steps’ buffer, and keeps its boundaries on that interval.
A decode step that ends a page keeps the state there, so a later turn that re-sends the reply restores it. It replaces the request’s previous decode snapshot once the KV chain keys its page; a step whose token never reaches the chain, such as the one speculated past the reply’s end, leaves the previous one in place.
A snapshot is keyed by the KV chain’s key at its page. A full set of slots evicts the least recently used snapshot that no request holds.
Snapshots stay off without prefix_slots, and also above one rank, without a named KV
cache, or when that cache’s prefix_cache is off; in those three cases the pool allocates
no snapshot slots. Every cache on the node then stays shut, and the engine logs which
resource kept it shut.
A model with a keyed label is checked at load time. Loading fails in two more cases:
A position resource over a keyed cache uses a scheme other than sequential. A page is reusable only where its tokens land at the positions they were written at. The message is
resource '<key>' places positions by <scheme>, but it sits over ['<kv key>'], which the model keyed for prefix reuse; either drop the stream or give the resource a sequential scheme.A resource on a keyed node does not set
prefix_skip_safe, and is neither a recurrent state pool nor a resource over pools alone. A hit takes the front of the request away from every resource on the node, including resources that never saw those tokens. The message isresource '<key>' cannot serve a request whose prefix came from the cache, but it sits on ['<node>'], which the model keyed for prefix reuse; either drop the stream or give the resource the prefix hooks.
Some requests stay uncached even when the model keys a label. The engine checks each
request’s inputs on the keyed walk and cuts a prefix only from inputs that
split_inputs can slice. A request whose inputs carry kwargs or
resource_step_info is never probed; BAGEL’s guided requests carry requires_cfg
there. The default split_inputs slices only the sequence, so a request whose inputs
carry tensor_inputs is probed only when the submodule sets cuts_tensor_inputs
and overrides split_inputs to cut those tensors. For a label keyed over several
walks, where a keyed node streams into a partition other than its own, a request that
runs that partition is not cached either, since a hit would skip the rows the node
streams for the tokens the cache holds. BAGEL
keys an understanding prompt’s text and images, and a generation’s first span when it
is text: an edit’s images are resized before their walks see them, and a file’s digest
doesn’t cover the resize. A label under a sliding window files pages only up to its
protected prefix, which the window never releases.
Step 2a — Declare your resources#
get_node_resources returns a flat list of NodeResourceSpec objects. Every spec has
these common members:
resource_keyis the name of this resource. Three places use this name, and all three must agree: the layers bind to it,declare_stepuses it as the key of its per-resource steps, and a deployment YAML tunes it underresources:(see Step 6). Existing models define these names as module-level constants in the model’sconfig.py, for exampleKV_CACHE = "kv_cache"andATTN = "attn", so that all three places read one definition.nodesis the set of graph-node names that share this resource. When two nodes name the same resource, they share one object. For example, BAGEL’sLLMnode and its CFG branch nodes share one KV pool. A node that no spec names receives no resources. Such a node was called “stateless” in earlier versions.depends_on()returns the keys of other specs that this spec is built against.AttentionSpec,PositionSpecandDSASpecdepend on thekv_cachekey they name. If a spec names a dependency that the model does not declare, loading fails immediately, rather than during a forward pass.RaggedAttentionSpecis the one attention spec that depends on nothing: it names no cache, because it has none.combined_splits()returns aCombinedSplitfor each combined walk this spec runs on split kernels (see combined walks); the default is none. The engine reads only this hook to order a split walk’s rows, so a spec whose config carriessplitsmust return them here.AttentionSpecandLinearAttnSpecreturn their config’ssplits.
Two lockstep-parallel nodes on one worker (TP or SP nodes placed on the same ranks) must not
share a resource whose state rank 0 decides for the other ranks outside the step that uses it:
a resource implementing supports_eviction, lease_cached_prefix or page_trail, which
the paged KV cache does with or without host offload. The other ranks replay each decision
with the next step of the node it was recorded under, so two nodes on one such resource could
admit out of step. The conductor refuses that placement (Resource.journals_resident_moves
says which resources it covers); give each node a spec of its own, or place the nodes on
different workers.
The spec types are:
Spec |
What it builds |
|---|---|
|
A paged KV cache. |
|
A fixed-capacity frame ring. It adds |
|
Fixed-size per-request KV with sink + window retention, and its attention. A
request’s stream is an optional read-only source (e.g. a voice prompt’s keys,
shared by the requests that use it) followed by what it writes; |
|
Self-attention planned over the named cache. |
|
DeepSeek sparse attention (DSA) over the named |
|
Attention over a paged context that is written once and never extended.
|
|
Cacheless (ragged) varlen self-attention over the segments packed into one
forward. Nothing is paged, and nothing carries to the next step.
|
|
The cross-attention counterpart: one span of each request attends another
( |
|
Block-causal self-attention within each span ( |
|
Position tracking and RoPE. |
|
A sampler with its own per-request parameter buffers, philox stream, and optional
seen-token mask. |
|
Fixed-size per-request state that carries between steps (an LSTM’s |
Orpheus declares four specs for its one autoregressive node. Its snac_decoder node
appears in no spec, so it receives no resources:
# mstar/model/orpheus/config.py
KV_CACHE, ATTN, SAMPLER, ROPE = "kv_cache", "attn", "sampler", "rope"
# mstar/model/orpheus/orpheus_model.py
def get_node_resources(self) -> list[NodeResourceSpec]:
kv_config = PagedKVConfig(
num_layers=self.config.num_hidden_layers,
num_kv_heads=self.config.num_key_value_heads,
head_dim=self.config.head_dim,
max_seq_len=self.config.max_position_embeddings,
num_qo_heads=self.config.num_attention_heads,
)
return [
KVSpec(resource_key=KV_CACHE, nodes={"LLM"}, config=kv_config),
AttentionSpec(
resource_key=ATTN, nodes={"LLM"},
config=AttentionConfig(kv_cache=KV_CACHE),
),
SamplerSpec(
resource_key=SAMPLER, nodes={"LLM"},
vocab_size=self.config.vocab_size,
enable_repetion_penalty=True,
),
PositionSpec(
resource_key=ROPE, nodes={"LLM"},
config=PositionConfig(
kv_cache=KV_CACHE,
rope_theta=self.config.rope_theta,
rope_scale=self.config.rope_scaling["factor"],
low_freq_factor=self.config.rope_scaling["low_freq_factor"],
high_freq_factor=self.config.rope_scaling["high_freq_factor"],
old_context_len=self.config.rope_scaling["original_max_position_embeddings"],
),
),
]
Which resources does a node need?
Node |
Declare |
|---|---|
Self-attending LLM (text decode, or an LLM used as a denoiser in a flow loop) |
|
Decoder of an encoder-decoder model |
The three or four specs above, plus a second |
An LLM with DeepSeek sparse attention (GLM-5.2) |
The specs above over an MLA cache ( |
A node that carries per-request state between steps of a loop (an RNN-T decoder, recurrent attention) |
A |
A node that samples with two different sets of parameters |
Two |
An encoder tower that attends within the segments of one packed forward |
A |
ViT, VAE or audio encoder, codec decoder, projection stage, combine stage |
Usually nothing. Declare no spec that names the node, unless the node needs the row above. |
Two samplers on one node#
If a node’s forward samples more than once per step with different parameters, declare
one SamplerSpec per parameter set. Both specs name the same node. Qwen3-Omni’s Talker
node does this. The Talker LLM samples codec group 0. The CodePredictor samples groups 1
to N-1, using its own vocabulary size and no repetition penalty:
SamplerSpec(resource_key=TALKER_SAMPLER, nodes={"Talker"},
vocab_size=self.config.talker_text.vocab_size,
enable_repetion_penalty=True),
SamplerSpec(resource_key=CODE_PRED_SAMPLER, nodes={"Talker"},
vocab_size=self.config.code_predictor.vocab_size,
enable_repetion_penalty=False),
The forward looks up each sampler by key and calls .sample() on it:
layer0 = engine_inputs.resources[TALKER_SAMPLER].sample(request_ids, logits)
code = engine_inputs.resources[CODE_PRED_SAMPLER].sample(request_ids, logits)
Each resource owns its own buffers. The two parameter sets are therefore independent, per-request, and safe under CUDA graphs.
Cross-attention (encoder-decoder models)#
A decoder that attends to a fixed encoder context declares two KV caches: its own self-attention cache, and a second cache that holds the context.
Use two separate resources, not two labels on one cache. The self-attention resource plans one wrapper per label of the cache it names. If the context shared that cache, the resource would re-plan the context on every decode step. The context is large and never changes, so this work would be wasted.
CrossAttentionConfig names both sides:
kv_cacheis the KV resource that holds the context.query_kv_cacheis the KV resource that drives the queries. Its plan defines how the step packs its queries. Set it toNoneif the query side caches nothing across steps. The packing is then taken from the cross-attention step’s own segments, with one entry per segment, in declaration order.context_labelis the label under which the context is written. The default is"context".
Whisper (mstar/model/whisper/) is the reference implementation:
KVSpec(resource_key=KV_CACHE, nodes={"decoder"}, config=kv_config),
AttentionSpec(resource_key=ATTN, nodes={"decoder"},
config=AttentionConfig(kv_cache=KV_CACHE)),
KVSpec(resource_key=CROSS_KV_CACHE, nodes={"decoder"}, config=context_kv_config),
CrossAttentionSpec(
resource_key=CROSS_ATTN, nodes={"decoder"},
config=CrossAttentionConfig(
kv_cache=CROSS_KV_CACHE,
query_kv_cache=KV_CACHE,
context_label=CONTEXT_LABEL,
),
),
There is no special API for writing the context. You express it in the step declaration.
The prefill step declares a non-zero span on the context cache’s KVStep. Every later
step declares a span of 0. A span of 0 reads the stream without extending it.
The encoder K and V tensors are written once per request by the decoder’s own code, in
whisper/components/decoder.py:write_cross_kv. That method iterates over the layers and
writes into the context label:
for layer_idx, layer in enumerate(self.layers):
k, v = layer.encoder_attn.compute_kv(encoder_states)
layer.encoder_attn.context_kv.set_default_layer_idx(layer_idx)
layer.encoder_attn.context_kv.write_kv(k, v, label=CONTEXT_LABEL)
A model with several context sources, such as “audio” and “image”, declares one
CrossAttentionSpec per source. Each source is a separate resource, and the source name
is also the key that the layer binds to.
Cacheless attention (encoder towers)#
A ViT or audio encoder attends within its own sequence and keeps nothing afterwards. Several images are packed into one forward, and each must attend only within its own span. That is varlen, or “ragged”, attention: the whole layout belongs to this step, and nothing carries to the next.
Such a tower needs no resource at all if it calls a varlen kernel directly, passing
cu_seqlens as an argument. Declare a RaggedAttentionSpec when the tower must run
inside a CUDA graph. The plan is what makes that possible: the engine plans the layout
outside the graph, into buffers whose addresses do not change, and the captured region
attends through them. A kernel that reads cu_seqlens as an argument, or builds its
mask from it, cannot be captured this way.
RaggedAttnBackend.AUTO (and an explicit FLASHINFER) plan through FlashInfer’s
ragged prefill wrapper. RaggedAttnBackend.DENSE plans the same layouts into static
cu_seqlens buffers and runs FA3 flash_attn_varlen (AITER on ROCm) instead, with
no page scatter. If that kernel is not importable, or the spec is a
RaggedBlockCausalAttentionSpec (whose overlapping prefixes varlen cannot express),
the factory logs a warning and builds FlashInfer. The fallback does not cover a GPU or
dtype the kernel lacks: fa3-fwd 0.0.3 is built for Hopper (sm_90a) and runs bf16
but not fp16.
BAGEL’s SigLIP2 tower is the reference implementation. The spec stands alone, over the encoder node only, and names no cache:
RaggedAttentionSpec(
resource_key=VIT_ATTN, nodes={"vit_encoder"},
config=RaggedAttentionConfig(
num_qo_heads=vit.num_attention_heads,
num_kv_heads=vit.num_attention_heads,
head_dim=vit.hidden_size // vit.num_attention_heads,
# one image, hence one attending segment, per request
max_segments_per_request=1,
),
)
A layer binds the key as it binds any other resource, then attends through the resource’s
run(q, k, v, label=None). There is no KV write to pair it with, so no
AttentionCallable is involved.
The step is an ordinary AttentionStep, and its segments are the entire layout.
There is no cache to read the layout off, so a label that carries no segment in the
declaration cannot be attended: the forward raises, rather than silently reusing the
previous step’s plan. Because the tower is normally captured as a piecewise region, that
declaration lives in the region’s declare_step, and names no KVStep. See
mstar/model/bagel/submodules.py, region "vit_block_loop", and Piecewise CUDA
graphs (capturing an inner loop).
Cross-attention between spans#
A diffusion transformer whose streams attend each other (video queries over text keys, or
audio over video) has several spans per request and no cache either. Declare each span as
its own label, and the cross-attentions as a RaggedCrossAttentionSpec beside the
RaggedAttentionSpec for the self-attention: a resource is one kind of attention. Its
step names the (q_label, kv_label) pairs:
RaggedCrossAttentionStep(
segments=segments, # one Segment per (request, label) the pairs use
pairs=(("video", "text"), ("audio", "video")),
)
Each pair is planned as its own layout, pairing each request’s q_label span with that
request’s kv_label span; cross-attention is never causal. A layer attends through
RaggedAttentionCallable(resource, cross_label(q_label, kv_label)), passing the key
span’s tokens as k and v. One resource has one head geometry, so a model whose
attentions differ in head count or head dim declares one spec per (kind, geometry).
LTX-2.5 is the reference (mstar/model/ltx2_5/submodules.py).
Called with return_lse=True, a ragged or ragged cross-attention callable on the FlashInfer
backend also returns each query row’s log-sum-exp ([total_tokens, H], fp32, natural log)
from the same plan, which is what merging attentions over disjoint key sets needs. Under sequence parallelism
LTX-2.5’s audio queries attend each rank’s share of the video tokens, and
attention_over_sharded_keys (mstar/model/components/distributed/sequence_parallel.py)
merges them.
Block-causal attention within spans#
A Whisper-style encoder trained for streaming masks attention by chunk: a frame in chunk
b sees every frame of chunks 0..b and nothing later. Declare it as a
RaggedBlockCausalAttentionSpec with the chunk length as block_size, and step it like
any ragged self-attention, one segment per independently attending span, with
AttentionStep(causal=False). Blocks are counted from each span’s start, so a span need
not be a multiple of block_size.
The prefixes overlap, which a ragged layout cannot express, so this kind runs FlashInfer’s paged prefill over the packed keys viewed as one-token pages, each query block listing its prefix; nothing is copied. Under a captured graph the block count and page list are sized for the worst layout the bucket’s token count allows.
Note
The eager varlen path is sometimes good to have. The BAGEL ViT has a head dimension of 72, which the FlashInfer kernel — it supports only a few fixed head dimensions — forces us to zero-pad to 128. So that tower attends through the resource only when it is replaying a captured graph, and runs flash-attn varlen otherwise.
Bounded (sink + window) caches#
A streaming decoder that keeps a fixed amount of context per request (a prompt or voice
prefix plus the last N steps, as StreamingLLM-style sinks or a streaming vocoder’s chunk
caches do) declares a BoundedKVSpec. A request takes a slot when it first runs and
keeps it; the window is a ring overwritten in place, so a step reserves and releases
nothing. A step’s layout is at most five key ranges and three write ranges per row,
computed on the host from the position the step declares, which is also what lets rows at
different positions share a batch and a captured graph.
The model calls attend(layer_idx, q, k, v, source, plan): each row attends its
retained keys in stream order and the step’s keys it writes (all of them for a row that
writes none), unmasked, and its writes land after. Pass
source ([sources, rows, H, L, 2 * head_dim], keys then values) when requests start
from shared prefixes that the slots should not copy; each row reads the one its
StreamPosition.source names, so rows of different prefixes share a batch and a graph. rel_bias adds a term that depends only on
the query-key distance, for Transformer-XL-style relative positions. A streaming vocoder
like MiniCPM-o’s token2wav fits three of them: its two conformer caches and its DiT’s.
Two flags cover retention rules that are not quite a token FIFO.
SinkWindow(window_includes_step=True) counts the step’s own tokens toward the window.
BoundedKVConfig(reverse_step_order=True) appends each step’s tokens last-first, which
only makes sense for attention that ignores key order; it decides which of the oldest
step’s tokens leave first.
By default the window is per step: every query of a step sees the same keys, the retained
ones and the step’s own. A streaming encoder whose frames each see a fixed left
context (a conformer’s att_context_size=[left, 0]) sets
BoundedKVConfig(retention=SinkWindow(0, left), query_window=left) instead: each query
attends the keys at distances 0..left from it, so a step of several frames is causal
within itself and matches the offline mask. rel_bias is then indexed by that distance,
[rows, H, T, left + 1], and passed unscaled (the kernel scales the sum by
head_dim ** -0.5). A per-query window takes no sink, and its right context is 0.
retention may be a single SinkWindow when it does not depend on the source length.
BoundedKVConfig(dtype=None) stores the cache in the engine’s KV dtype, for layers that
compute in it. The streaming FastConformer (encoder_kv_config in
mstar/model/components/fastconformer_stream.py, Nemotron VoiceChat’s ENCODER_KV) is
the per-query example.
Step 3 — Declare the computation graph#
get_graph_walk_graphs returns one graph per walk. The primitives are defined in
mstar/graph/base.py:
GraphNode(name, input_names, outputs)is one unit of compute.nameis the node name thatget_submodulereceives, and the name that resource specs put in theirnodesset.Model.nodesreturns all such names across all walks.input_nameslists the tensor names that must be present before the node can run.outputsis a list ofGraphEdge.GraphEdge(next_node, name, ...)routes an output tensor namednametonext_node. Two flags are important.persist=Truekeeps the tensor available for later steps and walks, which is how a generated token is carried fromprefillinto thedecodeloop.output_modality, combined withnext_node=EMIT_TO_CLIENT, streams the tensor to the client. Its value is one of"text","image","audio","video"or"action". The special destinationsEMIT_TO_CLIENTandEMPTY_DESTINATIONare defined inmstar/graph/special_destinations.py. Note that no edge flag stops adecodeloop. A loop stops when a submodule’scheck_stopregisters a stop signal against thatLoop, for example on EOS. See Step 4.Sequential([...])andParallel([...])compose subgraphs in order or concurrently.Loop(name, section, max_iters, outputs)is a subgraph that iterates. Its body feeds its own outputs back as the inputs of the next iteration. It runs at mostmax_iterstimes, and can stop earlier. A decode loop’smax_itersis the model’s context length, and each request’s own length is its cap fromget_loop_caps. Give the loop anameso that a submodule’scheck_stopcan register a stop signal against it. This is the usualdecodeloop.
A minimal text generator has two walks: a prefill node that runs once, and a
decode Loop whose body feeds its own output back as the next input.
def get_graph_walk_graphs(self) -> dict[str, GraphSection]:
prefill = GraphNode(
name="LLM",
input_names=["text_inputs"],
# the generated token persists so the decode loop can pick it up
outputs=[GraphEdge(next_node=EMPTY_DESTINATION, name="new_token",
persist=True)],
)
decode = Loop(
name="decode_loop",
section=GraphNode(
name="LLM",
input_names=["text_inputs"],
outputs=[GraphEdge(next_node="LLM", name="text_inputs")], # loop-back
),
max_iters=self.config.max_position_embeddings,
outputs=[],
)
return dict(prefill=prefill, decode=decode)
Step 4 — Implement the submodules#
Each node name maps to a mstar.model.submodule_base.NodeSubmodule, which is a
torch.nn.Module. Autoregressive nodes use the ARNodeSubmodule subclass. The
methods are:
prepare_inputs(self, graph_walk, fwd_info, inputs, **kwargs) -> NodeInputsConvert the routed
NameToTensorListinto a typedNodeInputs, or into anARNodeInputswithinput_idsorinput_embeds. This method runs once per request. Do only cheap host-side work here: shape and length bookkeeping, building position metadata, slicing token id lists. Do not launch GPU compute here. GPU compute belongs inforward. The engine may callprepare_inputson a different thread from the GPU thread, and much earlier than execution.Always set
input_seq_len. This field is on the baseNodeInputsclass and has two important uses. The engine sums it across the batch to select a CUDA-graph capture bucket and to compute padding sizes, anddeclare_stepnormally computes its spans from it. Leave it at 0 only when the submodule’s inputs are not sequence-shaped. Ifdeclare_stepneeds a value thatforwarddoes not use, put that value inresource_step_info.declare_step(self, graph_walk, request_ids, inputs, slot_lease=None, piecewise_leases=None, **kwargs) -> SubmoduleStep | NoneDeclare what this batch’s step does to the node’s resources. See Step 4a — Declare the step below.
preprocess(self, graph_walk, engine_inputs, inputs) -> dictCollate a list of
NodeInputsinto the keyword arguments thatforwardexpects. The default implementation handles batch size 1. Override it to support batching.ARNodeSubmoduledeclares this method abstract, because autoregressive submodules normally support continuous batching. You can still disable batching for one node, or for specific graph walks. See Step 5.forward(self, graph_walk, engine_inputs, **kwargs) -> NameToTensorListThe tensor-to-tensor computation. The keys of the returned dict are the edge names that the graph routes downstream. Read resources from
engine_inputs.resources[key]. See Reaching your resources.The engine applies
torch.compileto bothforwardandforward_batched, for every submodule. It also captures CUDA graphs for them when you declare capture configs (see Step 5). To disable compilation for a submodule, set the class attributedisable_torch_compile = True. An encoder whose output feeds a prefix cache layout walk compiles with dynamic shapes instead. One that marks every dimension that varies withtorch._dynamo.mark_dynamicinprepare_inputssetsmarks_dynamic_dims = Trueand compiles as any other submodule. Keep the compiled paths compile-friendly. If a helper must not be traced, because it uses data-dependent Python control flow or forces a host synchronization, exclude it explicitly with@torch.compiler.disable.Some submodules must run in their own parameter dtype. One example is an fp32 vocoder that is numerically sensitive. Such a submodule sets
disable_autocast = True. The engine then does not cast its parameters to the autocast dtype, does not wrap its forward in autocast, and explicitly disables any enclosing autocast. These two class attributes replace the removedget_stateless_flavormethod.The engine runs float32 matmuls as TF32 in every process (
torch.set_float32_matmul_precision("high")). A node that needs full fp32, for example a flow solve that amplifies TF32 rounding, setsfloat32_matmul_precision = "highest", andcudnn_allow_tf32 = Falsefor cuDNN convolutions and RNNs. The engine applies these around that node’s forward, its compile and its CUDA-graph capture, and restores the default afterwards, so other nodes keep TF32.None(the default) keeps the process setting. Don’t fliptorch.backendsflags in model code: they are process-wide, and a CUDA graph keeps the precision it was captured with. The setting coversforwardandforward_batchedonly, notpreprocessorpostprocess. It can also be set per instance in__init__, for example only when the model loads in fp32.postprocess(...)(optional)Metadata-only fixups that run on the GPU thread. This method must not read tensor values. Do not call
.item(),.cpu()or.tolist()here. This is a performance requirement, not a correctness requirement. Reading a value forces a host synchronization, which stalls the GPU thread and loses the worker’s asynchronous scheduling overlap. Use this method only to rename outputs for routing. Put decisions that depend on tensor values incheck_stop.check_stop(...) -> set[str] | StopCheck(optional)Runs off the GPU thread, and may read tensor values. Return the names of the
Loopobjects to stop, for example after seeing the EOS token. This is how a decode loop terminates. To also keep some of this step’s outputs from being routed, returnStopCheck(stops, withheld=(names...)), for example a frame streamed alongside the EOS token: its consumer then never receives it, instead of finding and dropping it with a device read.check_stop_batchedreturns aBatchedStopCheckfor the same. The Qwen3-TTS and Qwen3-Omni Talkers and Fish S2 Pro’s AR keep their EOS frame out of the codec stream this way.on_request_added(self, request_id, request_info)(optional)Called on the worker’s main thread when the request reaches the worker, before any of its steps, with the request’s first
CurrentForwardPassInfo. For host work worth starting early on a thread of the submodule’s own; it must not touch the device. The DiT scaffold draws a request’s seeded noise here (PREFETCH_SEEDS).cleanup_request(self, request_id)(optional)Free per-request state held inside the submodule when a request finishes: buffers, per-request caches, counters. See Qwen3-Omni’s
Code2WavSubmodulefor an example.filter_batched_output(...)andunpack_packed_outputs(...)(optional)Output fixups that run after the forward. A captured forward always emits the same set of keys, because the graph shape is fixed. Use
filter_batched_outputto drop keys that a particular request must not receive. Useunpack_packed_outputsto slice a packed(total_tokens, ...)tensor into per-request entries, when the slice boundaries depend on the real sequence lengths and therefore cannot be computed inside the captured region. Both methods are defined onNodeSubmodule, so any node can use them.spanned_outputs(graph_walk) -> frozenset[str](optional)Outputs whose valid length per row is known only on the device, such as audio with silence to trim.
forward_batchedreturns aBatchedModelOutputwhoseoutput_spansmaps the output’s name to a[bs, 2]int64 tensor on the device: rowiholds the first and one-past-last index of rowi’s valid samples along the output’s last dimension. A step that returns spans returns one for every output declared here, since per-request forwards are merged by name. The forward keeps a fixed width and does no host read. The worker cuts each row to its span after the step’s completion event, before the stop check, transport or a streaming edge reads it. The cut is a view, not a copy. Engine-sidepostprocessstill sees the uncut output. A row whose span is empty is left out of the store after the stop check, as a withheld output is (StopCheck): a streaming edge gets no item for it. A spanned output must not feed a node that the same worker speculates into.
Two more methods control batching and CUDA graphs: can_batch with
forward_batched, and get_cuda_graph_configs. They are described in Step 5.
Step 4a — Declare the step#
declare_step tells the engine what one batched step does to the node’s resources:
which cache streams it touches, how much each stream grows, what the attention plan is
based on, which streams fork, and what is committed after the forward completes.
The runner calls the declaration and the resource lifecycle around your forward:
declare_step() → admit → plan → preprocess → forward → commit
The runner owns this lifecycle. A submodule that declares a step must therefore contain no plan calls and no advance calls of its own.
Declaring the work also lets the worker handle a cache that is full. If a step cannot be
admitted, the engine returns an admit failure. The scheduler can then apply backpressure
and evict other requests. Without a declaration, the same situation raises a
RuntimeError inside the forward.
Returning None means that the submodule manages its own resources. This is the legacy
path, kept for submodules that have not been migrated.
A SubmoduleStep contains a list of Segment objects and one ResourceStep per
resource key:
Segment(request_id, label, span)is one request’s contribution to one cache stream in this step.spanis the number of tokens by which the stream grows. Aspanof 0 reads the stream without extending it: admission reserves nothing, and commit does nothing. A request contributes one segment per label that is active for it.The
segmentsargument ofSubmoduleStepis a default value. AnyResourceStepthat does not set its ownsegmentsuses this list.KVStep(commit=, eager_write=, combined_labels=, pre_forks=, post_forks=). See below.AttentionStep(causal=, context_only=)describes the attention plan.context_only=Truehas the queries attend to the stored context alone, not to the tokens this step appends, and the step’s ownsegmentsthen give the query counts. Only the MLA backend reads it; the others refuse it. ARaggedAttentionSpecsteps through this same type. There, the segments are the whole layout rather than an extension of a cache. See Cacheless attention (encoder towers).PositionStep(pos_ids=, advance=). Withpos_ids=None, positions are derived from the stream counters. Pass explicit ids, keyed by plan label, when the model computes positions itself.SamplerStep(apply_penalty=, prefill_tracked_tokens=, loopback_writes=).prefill_tracked_tokensinitializes the repetition-penalty mask with the prompt tokens. Only the prefill step passes them, because the sampler tracks every token it samples afterwards.loopback_writesnames the declared loop-back states this step’s forward stores (see Device loop-back).
The Orpheus declaration is the simplest form. It uses one label, computes spans directly from the prepared inputs, and steps all four resources together:
def declare_step(self, graph_walk, request_ids, inputs,
slot_lease=None, piecewise_leases=None, **kwargs):
prefill_tokens = {}
if graph_walk == "prefill":
prefill_tokens = {
rid: inp.input_ids
for rid, inp in zip(request_ids, inputs, strict=True)
}
return SubmoduleStep(
segments=[
Segment(request_id=rid, label="main", span=inp.input_seq_len)
for rid, inp in zip(request_ids, inputs, strict=True)
],
steps={
KV_CACHE: KVStep(),
ATTN: AttentionStep(causal=True),
SAMPLER: SamplerStep(apply_penalty=True,
prefill_tracked_tokens=prefill_tokens),
ROPE: PositionStep(),
},
)
Padding rows. Under a captured graph, the batch is padded to the shape of the capture
bucket, and request_ids also contains the ids of the padding rows. Declare segments
for those rows in the same way as for real rows. The zip(..., strict=True) in the
example above already does this.
The two leases. slot_lease is the CUDA-graph slot on which this step will replay.
It is None for an eager step. If a submodule’s declaration differs between the
captured case and the eager case, it must check the lease, not its own capture key. The
capture key only says that the batch could be captured. The lease says that the batch was
captured. For example, Cosmos3 packs both guidance branches into a single plan for the
captured shape, and uses the dense backend otherwise.
piecewise_leases names the inner regions of this node that hold their own slot for
this step. Such a region declares, plans and commits its own work. Any resource that the
region owns must therefore be excluded from the outer declaration. See
Piecewise CUDA graphs (capturing an inner loop).
Advanced ``KVStep`` fields. These fields cover cases that the engine previously
handled as special cases. BAGEL uses all of them, in
mstar/model/bagel/submodules.py:
commit=False: the step reads and plans, but its writes do not become resident yet.combined_labels={("main", "cfg_img"): "cfg_batched"}: pack several labels into a single plan. Batched classifier-free guidance uses this to run two branches through one attention call. Positions take their packing from the KV plan, so the grouping is declared once here, andPositionStepkeys itspos_idsby the combined label.pre_forksandpost_forks: a value such as(("main", "cfg_text"),)forks one cache stream from another.pre_forksforks before any planning or writing, for a branch that must keep the context from before this step.post_forksforks at commit time, for a branch that must include this step’s writes.eager_write=True: the step’s tokens for this cache are written outside any captured graph, for example inpreprocessbefore the replay. Their addressing stays an ordinary plan rather than the slot’s static buffers, which are sized for the bucket’s in-graph tokens. Whisper’s prefill uses it to write its 1500-token encoder context while replaying a graph sized by its few prompt tokens.
Writing K/V yourself. A layer that writes its K/V inside a fused kernel of its own,
rather than through write_kv, asks the KV resource where the step’s tokens go:
kv.write_target(layer_idx, label) returns (layer cache, page of each token, slot in
page of each token, number of tokens to write) for the current plan (NHD layout only).
Tokens past that number are the plan’s padding and must not be written. Qwen3-ASR’s
fused q/k norm, RoPE and KV-write kernel (Qwen3Attention) is the example.
Testing. declare_step performs only host-side bookkeeping, so you can unit-test it
on CPU without model weights. test/modular/vjepa2/fake_resources.py shows how to stub
the resources, and test/modular/test_resource_runner.py tests the runner’s lifecycle
directly.
Reaching your resources#
Two places need access to resources, and each has its own mechanism.
In the forward, read engine_inputs.resources, keyed by resource_key:
def _forward(self, graph_walk, engine_inputs, text_inputs):
sampler: SamplerResource = engine_inputs.resources[SAMPLER]
attn: AttentionManager = engine_inputs.resources[ATTN]
hidden = self.language_model(self.embed_tokens(text_inputs), label="main")
if graph_walk == "prefill":
hidden = attn.select_last_hidden(hidden)
return sampler.sample(engine_inputs.request_ids, logits=self.lm_head(hidden))
ModelInputsFromEngine carries six other useful fields:
stepis this step’sSubmoduleStep. A forward that must match its own declaration reads it here instead of computing the same information again.capturedis true when this forward runs under a CUDA-graph capture or replay. This field replaces the oldcache_manager.is_captured. It is useful whenpreprocesspacks its inputs differently for the fixed capture shape.syntheticis true when every row is a dummy request, during CUDA-graph capture or its warmup. Use it to keep statistics such as routing counts free of dummy traffic.real_seq_lenis the token count of the real requests. Their rows come first; any rows after them are padding that a replay adds to reach the bucket shape. It isNonewhen nothing was padded, so every row is real. Read it instead of assuming anything about the padding rows’ own inputs.per_request_statesis aMappingthat is resolved on first read.piecewise_runnersholds the piecewise CUDA-graph runners, keyed by region name.
In a layer, resources are resolved once at load time. The engine calls
submodule.bind_node_resources(resources). That method stores the resources, then
iterates over self.modules() and calls bind_resources(resources) on every module
that defines it. A layer names the keys it needs in its constructor and resolves them in
bind_resources. If a layer names a key that its node does not have, the error occurs
at bind time rather than during a forward pass:
ParallelAttention(..., attn_key=ATTN, kv_key=KV_CACHE, pos_key=ROPE)
Inside the layer, AttentionCallable (mstar/engine/resources/convenience.py) wraps
the KV write and the attention call together. The label and the layer index are cursors
stored on the resources. They are not passed as arguments on each call. The code that
drives the layer stack binds the label once, then advances the index for each layer:
def forward(self, query_sequence: torch.Tensor, *, label: str) -> torch.Tensor:
self.layers[0].self_attn.attend.bind_step(label)
for layer_idx, decoder_layer in enumerate(self.layers):
decoder_layer.self_attn.attend.set_layer_idx(layer_idx)
query_sequence = decoder_layer(hidden_states=query_sequence)
return self.norm(query_sequence) # the runner advances the cache, from the step
Cursors are used instead of arguments because passing the layer index as an argument makes inductor specialize on that integer. The frame is then retraced once per layer.
Device loop-back. A decode loop normally routes its sampled token back to itself as a
tensor per request. A node that sets reads_device_loopback = True can instead read
each row’s last sampled token off the sampler on the device. It names those signals in
device_loopback_signals(graph_walk), so the worker routes them without a tensor, and
reads them in preprocess with sampler.loopback_tokens(request_ids). A row whose
last step did not keep its value on the device (its first decode step, after the prefill)
still brings it routed; put it over the one read from the device with
sampler.overwrite_rows(rows, (ids, routed_ids)).
State that a decode loop carries from step to step beside the token, such as Whisper’s timestamp rule state, is declared on the sampler, not kept in the model:
SamplerSpec(
resource_key=SAMPLER, nodes={"decoder"}, vocab_size=vocab,
loopback_state={"ts_rules": LoopbackState(
width=4, dtype=torch.long, default=tuple(inactive_state()),
)},
)
The sampler keeps one row per request by slot, grows it with its other per-request
buffers, and resets a row to default when its slot is given to a new request. A step
reads the rows with sampler.loopback_state(name, request_ids) in preprocess and
stores the next ones from the forward with sampler.store_loopback_state(name,
request_ids, values). Under a CUDA-graph slot the store is captured into a per-step
buffer and commit persists it, so the step must list the name in
SamplerStep(loopback_writes=...). An eager step writes the rows by request. Both paths
address rows the same way, and the model never sees a slot index.
Warning
Where the callable is passed into a traced function, create one AttentionCallable
per transformer, not one per layer. Dynamo specializes ulysses_attention on the
identity of its run_attention argument. A per-layer callable therefore retraces
that frame once per layer and exceeds the recompile limit. The shared Attention
layer creates one callable per layer. That is safe only because the callable is never
passed into a traced function.
Loading weights#
get_submodule loads a node’s parameters. Weight loading is standardized through
mstar/model/loader/. Use it instead of a custom load_state_dict call. Only then
can the same code load both a single-GPU checkpoint and a tensor-parallel shard. See
Step 6. There are three layers:
In
get_submodule, build thenn.Moduleon themetadevice, materialize it withto_empty(device=...), then callload_weights(module, source, device=...)frommstar.model.loader. This function selects the correct safetensors iterator, for a single file or for a sharded Hugging Face directory, and then callsmodule.load_weights(weights).Your module implements
load_weights(self, weights)and delegates toload_hf_weights(self, weights, stacked_params=..., name_remapper=...). That function streams the(name, tensor)pairs and dispatches each pair to theweight_loaderof the matching parameter.stacked_paramsis a list ofStackedParamRule. Each rule routes several checkpoint keys into one fused parameter, for example the Hugging Faceq_proj,k_projandv_projkeys into a singleqkv_projparameter.LLAMA_STACKED_PARAMSis a predefined rule set for Llama models.name_remapperrewrites or drops checkpoint keys that do not match your parameter paths.
# in the Model: build on meta, materialize, hand off to the driver
def _create_llm_submodule(self, device, tp_group=None):
from mstar.model.loader import load_weights
with torch.device("meta"):
language_model = OrpheusForCausalLM(self.config, comm_group=tp_group)
language_model.to_empty(device=device)
load_weights(language_model, local_dir, device=device) # → module.load_weights(...)
...
# in the nn.Module: declare the fused-shard routing and delegate
def load_weights(self, weights):
from mstar.model.loader import LLAMA_STACKED_PARAMS, load_hf_weights
return load_hf_weights(self, weights, stacked_params=LLAMA_STACKED_PARAMS)
Each parameter’s weight_loader also performs tensor-parallel sharding. When the module
is built with a comm_group and tp_world_size > 1, the loader slices the incoming
tensor along that parameter’s shard dimension before copying it. For this reason, one
load_weights path serves both single-GPU runs and tensor-parallel runs without change.
Step 5 — Continuous batching and CUDA graphs#
Continuous batching and CUDA graphs are the two main throughput optimizations. Both are optional. For an autoregressive node, you normally want both. These two mechanisms have the most detailed rules in the submodule interface, so they are described here separately.
Continuous batching. The worker’s micro-scheduler groups compatible in-flight requests into one GPU call. A submodule controls this behavior with three methods:
can_batch(self, batch: ExecutingBatch, model_inputs) -> boolreturns whether these requests can share one forward pass. The default isFalse, which disables batching. Override it to accept batches, for example when the requests use the same graph walk and have compatible shapes.forward_batched(self, graph_walk, engine_inputs, **kwargs) -> dict[str, NameToTensorList]performs the batched compute. It returns per-request outputs, keyed byrequest_id. When a batch runs, the engine calls this method instead of the single-requestforward.max_batch_size(self, graph_walk)sets an optional upper limit.
Combined walks. By default a step holds one walk. A model lets several walks of a node
share a step by returning {node: {combined_walk: {walks}}} from
get_combined_graph_walks, for example decode rows beside a prompt’s prefill. The
combined walk is a name of its own, which max_batch_size and the CUDA-graph configs
can key on. A step of one walk keeps that walk’s cap. A step that mixes walks takes the
combined walk’s max_batch_size when it has one, else the smallest cap of the walks it
holds, so a small prefill cap never limits a decode-only step.
max_rows_in_combined(self, graph_walk) also bounds how many rows of one walk a mixed
step may hold, for kernels sized to a few prompt rows (see CombinedSplit). To run a
combined walk on such split kernels, pass a CombinedSplit in the splits of each
resource config that supports one (AttentionConfig on the FLASHINFER backend, for
captured steps only, and LinearAttnConfig with the GDN variant; other backends ignore
splits and run the walk unsplit); their specs return it from combined_splits(),
which is what the engine reads, and resources of one node must agree on a walk’s
prefill_walks. The engine then orders that walk’s steps with the prefill_walks
rows last, so the kernels read the decode rows as the step’s first tokens, in place. Every
other walk of the combined walk must be one token per row.
ARNodeSubmodule declares preprocess abstract because autoregressive nodes are
expected to collate a batch. You can still disable batching for one node, or for specific
walks, by returning False from can_batch in those cases.
CUDA graphs. A submodule declares the shapes it can capture in
get_cuda_graph_configs(self, device, tp_world_size=1) -> list[CudaGraphConfig]. The
default is an empty list, which means eager execution. For each config, the engine first
runs torch.compile, controlled by the config’s compile flag, which defaults to
True. It then records a CUDA graph and replays it. Two config types are defined in
mstar/engine/cuda_graph_config.py. They differ in which stage of the submodule
pipeline they freeze:
Config type |
Use and captured stage |
|---|---|
|
Decode-style forward passes, in which every request in the batch has the same
length. That length is usually one token. Pass |
|
Prefill-style forward passes that operate on packed, variable-length sequences.
Pass |
Both types share the base CudaGraphConfig fields:
capture_graph_walkis the walk to capture.replay_graph_walkslists the walks that may replay this capture. One capture can therefore serve several walks, for exampleprefill_audioreusingprefill_text.capture_batch_sizeslists the batch sizes to record.additional_key_infois an extra hashable component of the capture bucket key. Use it when one walk must be captured more than once because the batch shape alone does not identify the capture. Its value must equal both the value returned by the submodule’scg_key_info(graph_walk, per_request_info)for a batch and thecg_key_infovalue that the batch’sdeclare_stepsets on its step. A mismatch raises no error. The batch simply does not match any capture and runs eagerly. Compute all three values from one place. This field replaces the removedlabelsandrequires_cfgfields.capture_forward_methodnames the method to capture. The default is"forward_batched".caps_eager_batch_sizecontrols whether this config’s captured sizes also limit the engine’s eager batch size for the walk. The default isTrue, so the engine never batches beyond a captured size.compilerunstorch.compilebefore capture. The default isTrue.compile_modeis thattorch.compile’s mode. The default,None, takes the deployment’s compile level (mstar serve -O,MSTAR_COMPILE_LEVEL); naming a mode takes the config out of that level.defaultkeeps Inductor’s fusion but takes cuBLAS for every GEMM and skips autotuning.deterministicis the engine’s own: Inductor’s defaults in its deterministic mode, which picks kernel configs by rule rather than timing them on the device, so every process compiles the same kernels (a torch without the mode logs a warning and compiles with the defaults). A compile outside the runner takes the same options fromaccelerator_graph_runner.compile_kwargs("deterministic"), ordeterministic_inductor_options()where it builds its own; the Wan VAE decode, the DiT scaffold’scompile_blocksand Cosmos3’s denoise step do. Building the config refuses an unknown mode, and one that turns on Inductor’s own CUDA graphs (reduce-overhead,max-autotune), which fail inside the runner’s capture.requiredmakes every bucket in the config mandatory. If capture fails locally or on another participating rank, warmup raises after rank-wide agreement instead of dropping the bucket and falling back to eager execution. The default isFalse.
BatchedCudaGraphConfig also accepts total_tokens_multiplier. Use it when one
request’s step commits KV across several labels that are combined into a single plan, as
in batched guidance that packs the conditional and unconditional sequences together. The
static buffer must hold all of them, and this field scales the buffer independently of the
per-label span.
For example, the Orpheus LLM submodule captures a batched decode graph and a packed
prefill graph:
PREFILL_TOKEN_BUCKETS = [32, 64, 128, 256, 512, 1024]
PREFILL_CAPTURE_BATCH_SIZES = [1, 2, 4, 8, 16]
def get_cuda_graph_configs(self, device, tp_world_size=1):
return [
BatchedCudaGraphConfig(
capture_graph_walk="decode",
single_request_inputs=ARNodeInputs(
input_ids=torch.zeros(1, dtype=torch.long, device=device),
input_seq_len=1,
),
),
PackedCudaGraphConfig(
capture_graph_walk="prefill",
capture_token_lengths=self.PREFILL_TOKEN_BUCKETS,
make_node_input=lambda n: ARNodeInputs(
input_ids=torch.zeros((n,), dtype=torch.long, device=device),
input_seq_len=n,
),
capture_batch_sizes=self.PREFILL_CAPTURE_BATCH_SIZES,
),
]
BAGEL shows the case of two captures for one walk. It captures decode twice, once with
additional_key_info=False and once with additional_key_info=True. A decode step
with guidance enabled and a decode step with guidance disabled declare different segments
over the same token count. BAGEL’s cg_key_info reports which of the two a batch is.
Piecewise CUDA graphs (capturing an inner loop)#
The configs above capture the whole forward_batched method of a submodule. The engine
then drives the replay, including sampling and output remapping.
Sometimes you want to capture only one inner region of a forward, such as a transformer block loop, and keep the surrounding code in eager Python. The code before the region computes embeddings and assembles the sequence. The code after it applies the final norm and projection. A piecewise CUDA graph supports this.
A submodule enables piecewise capture by returning one or more configs from:
get_piecewise_cuda_graph_configs(self, device, autocast_dtype, tp_world_size=1)
-> dict[str, PiecewiseCudaGraphConfig]
The dict key is a region name, which is any string that identifies the captured region.
Several keys capture several independent graphs. At warmup, the engine builds one
PiecewiseCudaGraphRunner per key and puts them in
engine_inputs.piecewise_runners. Your forward looks up the runner by key and calls it.
Nothing is stored on the submodule.
Two config types are defined in mstar/engine/cuda_graph_config.py. They correspond to
the two whole-forward types:
Config type |
Use and captured shape |
|---|---|
|
Batched inputs of equal length. The captured region sees |
|
Packed, variable-length inputs. The captured region sees |
Both types share the base PiecewiseCudaGraphConfig fields:
capture_fnis the callable to capture. Its interface is described below.make_static_inputsis a factory with signature(shape: PiecewiseCaptureShape) -> dict[str, Tensor]. It returns the persistent buffers that the captured region reads. The runner owns these buffers. Before each replay, it copies your real tensors into them by name.shapeprovidesbs,seq_lensandtotal_tokensfor the bucket being built. Allocate the hidden-state buffer with dtypeautocast_dtype, so that the copy before replay does not convert dtypes.forward_kwargsholds static keyword arguments that are passed tocapture_fnon every call, for example a layer count or anis_causalflag.declare_stephas signature(request_ids, seq_lens) -> SubmoduleStep | None. It declares the region’s own resource work over the padded batch. It is separate from the submodule’sdeclare_stepbecause the region has its own shape. The runner admits, plans and commits this step on each replay. This field replaces the removeduses_kv_cache,plan_fn,advance_seq_lensandcache_labelsfields. A region that reads a KV cache declares aKVStepand anAttentionStephere. A cacheless region that uses ragged attention declares only anAttentionStep, against itsRaggedAttentionSpeckey.lease_before_stepmakes the runner take this region’s CUDA-graph slot before the outerdeclare_stepruns, and report the slot in that call’spiecewise_leasesargument. The region still declares, plans and commits its own work. The lease only tells the outer declaration which resources are already taken, so that it can exclude them.can_leasehas signature(inputs: list[NodeInputs]) -> bool. It applies only withlease_before_step, and lets the region refuse a batch for a limit that the capture bucket key doesn’t express, such as Qwen3.5’s per-request segment count. The engine then gives the region no lease for that step. Your forward must replay only when the region was leased, for example when the outer step isNone, and not wheneverrunner.can_runis true: a refused batch can still fit a bucket, and replaying it would declare the region’s resources twice.capture_batch_sizeslists the batch sizes to record.Noneuses the runner default.compilerunstorch.compileoncapture_fnbefore capture. The default isFalse.compile_modeis thattorch.compile’s mode, as onAcceleratorGraphConfig.requiredmakes every declared shape mandatory. If any participating rank cannot capture one, warmup raises instead of leaving that shape on the eager path. The default isFalse.
Splitting the declaration. When a region leases its own slot, exactly one of the two
declarations must own each resource. The common pattern is for the outer declare_step
to return None when the region holds a lease, and to declare the resources itself
otherwise, which covers the eager fallback path:
def declare_step(self, graph_walk, request_ids, inputs,
slot_lease=None, piecewise_leases=None, **kwargs):
if (piecewise_leases or {}).get(BLOCK_LOOP_REGION):
return None # the region declares its own KV + attention
return SubmoduleStep( # eager path: nobody else reserves these pages
segments=[
Segment(request_id=rid, label="main", span=inp.input_seq_len)
for rid, inp in zip(request_ids, inputs, strict=True)
],
steps={KV_CACHE: KVStep(), ATTN: AttentionStep(causal=False)},
)
The capture_fn interface. The captured callable takes one PiecewiseCallInputs
argument and returns a dict:
capture_fn(inp: PiecewiseCallInputs) -> dict[str, Tensor]
PiecewiseCallInputs has four fields. static_inputs holds the buffers owned by the
runner. engine_inputs is the region’s view of the batch: request ids and per-request
info padded to the capture bucket, plus the node’s resources. kwargs is the config’s
forward_kwargs, unchanged. resources is a shortcut for
engine_inputs.resources. Capture and replay both use this same type, so a region
cannot read a field that exists on only one of the two paths.
Warning
Read tensors from inp.static_inputs, and never assign to its entries. The runner
passes the same dict object at capture time, and updates those buffers in place before
each replay. An assignment such as static_inputs["x"] = ... replaces the buffer,
and the region then no longer uses the memory address that the graph recorded.
A capture_fn may also return a single Tensor. The runner wraps it as
{"x": ...}.
Calling the runner. Look up the runner by region name and pass your real inputs to it.
It returns a PiecewiseOutput, which behaves like a dict. Indexing and .get return
a clone that you own and can keep. .get_view returns a view without copying, which is
only valid until the next call to run. See mstar/engine/cuda_graph_runner.py. The
runner handles input padding, admission and planning of the region’s declared step,
replay, commit, and output slicing.
The V-JEPA2 AC predictor is the reference implementation. See
mstar/model/vjepa2/submodules.py, region "block_loop". It has an eager section
before the region, a captured block loop that reads the KV cache over a fixed per-step
sequence, and an eager section after the region.
from mstar.engine.cuda_graph_config import (
PiecewiseBatchedConfig, PiecewiseCallInputs, PiecewiseCaptureShape,
)
# --- the captured inner region ---
def _block_loop_capture(self, inp: PiecewiseCallInputs) -> dict[str, torch.Tensor]:
# READ out of inp.static_inputs; never reassign its entries
cond_tokens = inp.kwargs.get("cond_tokens")
fn = self.predictor.make_block_loop_fn("main", inp.static_inputs, cond_tokens)
return {"x": fn(inp.static_inputs["x"])}
# --- declare the region ---
def get_piecewise_cuda_graph_configs(self, device, autocast_dtype, tp_world_size=1, **kwargs):
def make_static_inputs(shape: PiecewiseCaptureShape) -> dict[str, torch.Tensor]:
# hidden state in autocast_dtype so the replay copy_ is a same-dtype memcpy;
# position buffers stay float32 (RoPE frequency precision matters more)
return {
"x": torch.zeros(shape.bs, capture_seq_len, embed_dim,
dtype=autocast_dtype, device=device),
"d_pos": torch.zeros(N * N, dtype=torch.float32, device=device),
...
}
def declare_step(request_ids: list[str], seq_lens: list[int]) -> SubmoduleStep:
return SubmoduleStep(
segments=[
Segment(request_id=rid, label="main", span=seq_len)
for rid, seq_len in zip(request_ids, seq_lens, strict=True)
],
steps={KV_CACHE: KVStep(), ATTN: AttentionStep(causal=False)},
)
return {
BLOCK_LOOP_REGION: PiecewiseBatchedConfig(
capture_fn=self._block_loop_capture,
make_static_inputs=make_static_inputs,
declare_step=declare_step,
# take the slot before the outer declaration, so it can leave
# this region's KV to the runner
lease_before_step=True,
seq_len=capture_seq_len,
forward_kwargs={"cond_tokens": cond_tokens},
capture_batch_sizes=[1, 2, 4, 8],
)
}
# --- invoke it inside the forward ---
runner = engine_inputs.piecewise_runners.get(BLOCK_LOOP_REGION)
if runner is not None and runner.can_run(x.size(0)):
out = runner.run( # admits + plans + replays + commits
static_inputs={"x": x, "d_pos": d_pos, ...},
request_ids=engine_inputs.request_ids,
)
x = out["x"] # owned clone
Three points in this example are worth noting. First, positions are computed eagerly and
passed in through static_inputs, so the captured region does not compute them again.
Second, the same block loop is used on both the captured path and the eager path, so the
code exists in one place only. Third, the region’s declare_step covers only the
captured path. The eager path is covered by the submodule’s own declare_step, as shown
under Splitting the declaration above.
BAGEL’s ViT tower is the packed, cacheless counterpart. See
mstar/model/bagel/submodules.py, region "vit_block_loop": a
PiecewisePackedConfig whose region declares only ragged attention, with patch
embedding and the RoPE gathers left eager because they are data-dependent indexing.
Step 6 — Write a config YAML#
A config maps nodes to GPU ranks. The value under model: is your registry key. Each
node_groups entry assigns one or more node_names to ranks. An entry can also
name specific graph_walks, which is how prefill-decode disaggregation is expressed.
Every node of a walk must be placed by exactly one entry that applies to that walk; the
conductor refuses a config that places it twice or not at all.
model: "your_model"
max_seq_len: 2048
node_groups:
- node_names: ["LLM"]
ranks: [0]
Run it with:
mstar-serve --config configs/your_model.yaml --host 0.0.0.0 --port 8000
Tuning resources per deployment#
The model declares the shapes that the model requires. A deployment then tunes the values
that suit the machine it runs on. Use a resources: block for this, with one sub-block
per resource_key. A model with two caches of the same kind, such as Whisper’s decoder
cache and its encoder context, can therefore tune each one separately:
model: "whisper_large"
max_seq_len: 448
resources:
kv_cache:
cpu_offload_pages: 128
cross_kv_cache:
cpu_offload_pages: 128
Each spec declares which keys it accepts. An unknown key raises an error at load time, so that a misspelled setting is never silently ignored:
Spec |
Accepts |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Tune the cache shape on the KV resource, not on the attention resource that reads it. For
example, configs/qwen3tts.yaml selects FA2 under talker_attn,
configs/cosmos3_nano.yaml sets the page count under its paged KV key, and
configs/waypoint.yaml sets the resident world count under its ring KV key.
Note
A top-level kv_cache: block is no longer read. It raises an error at load time,
with a message describing the migration. Move the block under resources:, keyed by
the resource name.
Preprocessing threads#
The API server runs load_image / process_prompt and output encoding with torch’s
intra-op pool. Torch sizes it at one thread per host core, which in a container can be
several times the CPUs the cgroup quota lets run, and an op waits on any of its threads
the scheduler has parked. The API server therefore defaults to a quarter of the CPUs it
may use (its affinity, capped by the cgroup cpu.max quota), at most 4. A model sets
its own size with PREPROCESS_TORCH_THREADS on the class (Qwen3.5 uses 4). A
deployment overrides either at the top level of its config; null keeps torch’s
default:
model: "qwen3_5_4b"
preprocess_torch_threads: 4
The setting applies only to the API server process. The conductor and workers keep torch’s default.
A pool that small is slow for a large copy, and a 121-frame 832x480 video is 145 MB. A
video decoder should therefore emit its clip in the layout the encoder takes, transposed
on the GPU inside its forward, so postprocess hands the tensor it reads straight to the
encoder: [T, C, H, W] for torchcodec’s VideoEncoder (Cosmos3) and [T, H, W, 3]
rgb24 frames for PyAV (Wan2.2, LTX-2.5) or a raw video_frame stream (LingBot-World).
A permute in postprocess costs a host copy of the whole clip, or a slower copy of each
frame inside PyAV. The arena’s CPU read of an output (MSTAR_SHM_ARENA) slices a large
copy over threads of its own (mstar.utils.host_copy.HostCopier), one encode’s share of
the CPUs.
Tensor parallelism (sharding)#
To shard a node across several GPUs, add tp_size to its node_groups entry and list
tp_size ranks. The runtime splits the group’s ranks into TP groups of that size and
builds one comm_group per shard.
A node is sharded only if its components are built from the tensor-parallel modules in
mstar/model/components/distributed: ParallelAttention, ParallelCrossAttention, ParallelGatedMLP,
ColumnParallelLinear, RowParallelLinear, VocabParallelEmbedding and others.
The weight_loader of each such parameter slices it automatically. See Loading
weights. A node whose components do not use these modules is replicated on every rank
instead. For example, a tensor-parallel Qwen3-Omni Talker keeps its code predictor
replicated.
Once a component is built from these modules, moving from one GPU to tensor parallelism
requires only the YAML change and a small sharding declaration. No model code changes. The
example below runs the Orpheus LLM with tensor parallelism across two GPUs. See
configs/orpheus_tp2.yaml:
model: "orpheus"
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]
For a node to be eligible for tp_size > 1 it must be declared TP-enabled by the model.
Override get_default_sharding_config to return a ShardingConfig that names the
shardable nodes, and any non-default shard dimensions:
def get_default_sharding_config(self):
from mstar.distributed.base import ShardingConfig
return ShardingConfig(groups=[], tp_enabled_nodes={"LLM"}, shard_dim={})
Two separate mechanisms split work across the TP group. Keep them distinct:
Weights are sharded inside the components, by each parameter’s
weight_loader. Column-parallel and row-parallel linear layers built with thecomm_groupdo this automatically, once the module is constructed for tensor parallelism. The config needs nothing more.Activations that cross a node boundary are handled by
shard_dimin theShardingConfig. It maps an inter-node edge or signal name to the dimension along which that tensor is split across the group. If a name is absent, or maps toNone, the tensor is replicated to every rank. You need an entry only for edges where the producer and the consumer both keep the data sharded. The common case is replicated activations, which needs no entry. You can also setshard_dimper run, under asharding_configblock in the YAML.
If a node group has tp_size > 1 and names a node that is not in tp_enabled_nodes,
loading fails. See configs/qwen3omni_thinker_tp2.yaml for an example with several node
types.
Expert parallelism (MoE)#
A sharded MoE node can split its routed experts in two ways. Under TP, the default, every
rank holds a slice of every expert. Under EP, each rank holds whole experts. A deployment
picks one per node group with an ep_config block; see Serving for the keys. The
shared pieces are in mstar/model/components/moe_parallel.py. The Qwen3-Omni Thinker
(mstar/model/qwen3_omni/components/thinker.py) is the reference user.
To support it in a model:
Declare the node EP-enabled. Add it to
ep_enabled_nodesinget_default_sharding_config. Anep_configon any other node fails at start-up.return ShardingConfig( groups=[], tp_enabled_nodes={"LLM"}, shard_dim={}, ep_enabled_nodes={"LLM"}, )
Build the MoE layers with
instantiate_moe_block. Acceptmoe_parallelinget_submoduleand pass it, with the node’stp_group, down to each MoE layer. The helper returns aParallelSparseMoeBlockor anExpertParallelSparseMoeBlock, whose weight loaders keep only the rank’s slice or its own experts.moe_parallel=Nonegives TP.self.mlp = instantiate_moe_block( hidden_size=cfg.hidden_size, moe_intermediate_size=cfg.moe_intermediate_size, num_experts=cfg.num_experts, num_experts_per_tok=cfg.num_experts_per_tok, norm_topk_prob=cfg.norm_topk_prob, comm_group=tp_group, moe_parallel=moe_parallel, )
Optionally, mix in
MoeParallelMixinon the model’s top-level module, and callself.init_moe_parallel(moe_parallel)at the end of its__init__. It provides:maybe_log_expert_load(synthetic), which implementslog_expert_load_every. Call it from eager code before each forward, usually frompreprocess, withsynthetic=engine_inputs.syntheticso capture and warmup routing are discarded.moe_token_valid(hidden_states, num_valid_tokens), which implementsmask_padding. It returns thetoken_validmask for the MoE blocks, orNonewhen masking is off. Pass the mask only to MoE layers, not dense MLPs.num_valid_tokensmust be a(1,)device tensor thatpreprocessreturns as a static input, so a replay can update it; fill it fromengine_inputs.real_seq_len.ThinkerSubmodule.preprocessshows the wiring.
The shared-expert block (ParallelSparseMoeBlockWithSharedExpert) has no EP variant, so
a node built from it stays TP-only.
Worked example: Orpheus#
Orpheus (mstar/model/orpheus/) is a small and complete reference. It is a TTS model. A
Llama 3.2 3B LLM emits audio tokens, and a SNAC decoder converts them into 24 kHz PCM.
The two nodes have different needs. The LLM declares the four standard resources, shown
under Step 2a. No spec names snac_decoder,
so that node receives no resources. The SNAC decoder instead declares how it must be run,
using two class attributes on the submodule:
class SNACDecoderSubmodule(NodeSubmodule):
disable_torch_compile = True # runs in fp32, without compilation
disable_autocast = True
There are three graph walks: prefill and a decode Loop on the LLM, plus a
snac_chunk node that emits audio to the client:
snac_chunk = GraphNode(
name="snac_decoder",
input_names=["new_token"],
outputs=[GraphEdge(next_node=EMIT_TO_CLIENT, name="audio_chunk",
output_modality="audio")],
)
process_prompt formats the string "{voice}: {text}", tokenizes it, wraps the ids in
the model’s start and end tokens, and returns {"text_inputs": [ids]}.
get_submodule builds either the Llama LLM submodule, which is an ARNodeSubmodule,
or the SNAC decoder submodule, and caches the result. postprocess returns the raw
bytes of the audio tensor for the audio modality.
Orpheus also demonstrates the async partition API, described in the next section. The LLM and the SNAC decoder run as two partitions connected by a streaming edge. Audio is therefore decoded in a sliding window while the LLM is still generating.
Worked example: BAGEL#
Orpheus is a single pipeline. BAGEL (mstar/model/bagel/) is much more complex, and it
shows why the graph abstraction is useful. BAGEL is a unified model. It performs image
understanding, which maps an image to text, and image generation, which maps text to an
image. Both use the same Qwen2 LLM. That LLM is also the denoiser for rectified-flow image
generation. The steps below follow the same order as the rest of this page.
Step 1 — Register. This is already done in registry.py, with the entry
"bagel": BagelModel and an HF_MODELS entry that points to
ByteDance-Seed/BAGEL-7B-MoT.
Step 2 and 2a — Nodes and resources. The model has four core nodes: a ViT encoder
(SigLIP2, used for understanding), a VAE encoder (FLUX, used for editing and generation),
the LLM (Qwen2, which contains the embedding, the transformer, the lm_head and the CFG
logic), and a VAE decoder. It has four more nodes for the CFG-parallel image-generation
path described below: init_latents, the two branch nodes LLM_cfg_text and
LLM_cfg_img, and combine_cfg.
The three LLM nodes share one set of resources: the same KV pool, attention, positions and
sampler. Each of those specs names all three nodes in its nodes field. The ViT encoder
declares one resource of its own, a RaggedAttentionSpec with no cache behind it, so
that its block loop can be CUDA-graph captured:
def get_node_resources(self) -> list[NodeResourceSpec]:
nodes = set(self._LLM_NODES) # {"LLM", "LLM_cfg_text", "LLM_cfg_img"}
return [
RaggedAttentionSpec(resource_key=VIT_ATTN, nodes={"vit_encoder"},
config=RaggedAttentionConfig(...)),
KVSpec(resource_key="kv", nodes=nodes, config=self._kv_config(), leader="LLM"),
AttentionSpec(resource_key="attn", nodes=nodes,
config=AttentionConfig(kv_cache="kv")),
PositionSpec(resource_key="rope", nodes=nodes,
config=PositionConfig(kv_cache="kv",
rope_theta=self.config.rope_theta)),
SamplerSpec(resource_key="sampler", nodes=nodes,
vocab_size=self.config.vocab_size),
]
No spec names the VAE encoder, init_latents, combine_cfg or the VAE decoder, so
those nodes receive no resources. The CFG nodes are always declared, but they are used
only when the config enables CFG-parallel mode, described under Step 6. A single-GPU
config never routes requests to them.
Per-request resources. Whether a request uses classifier-free guidance determines
which cache labels it reads. This is a property of the request, not of the deployment.
BAGEL’s get_request_resource_configs therefore returns a KVReqConfig that names
the active labels per node and walk, together with a SamplingReqConfig. A request with
guidance disabled names only "main", so a PD-disaggregated transfer copies only that
stream.
The LLM node is deliberately coarse. It contains the text embedding, the lm_head and
the flow projection. These always run on the same GPU, so splitting them into separate
graph nodes would only add IPC overhead. This is a general modeling rule: make a node as
coarse as the colocation boundary allows.
Step 3 — Graph walks. Understanding and generation are different pipelines, so BAGEL
returns five walks from get_graph_walk_graphs instead of two:
Graph walk |
What it does |
|---|---|
|
Embed text tokens and prefill the LLM. Attention is causal. |
|
|
|
|
|
Autoregressive text generation. This is a |
|
The flow-matching denoising |
The two encoder walks are Sequential chains of two nodes. image_gen is a Loop
followed by the decoder. In the loop body, latents and time_index are routed back
to the same node, and the loop’s outputs pass the final latents to vae_decoder:
prefill_vit = Sequential([
GraphNode(name="vit_encoder", input_names=["image_inputs"],
outputs=[GraphEdge(next_node="LLM", name="img_emb")]),
GraphNode(name="LLM", input_names=["img_emb"],
outputs=[GraphEdge(next_node=EMIT_TO_CLIENT, name="new_token",
output_modality="text", persist=True)]),
])
image_gen = Sequential([
Loop(
section=GraphNode(
name="LLM",
input_names=["latents", "time_index"],
outputs=[GraphEdge(next_node="LLM", name="latents"),
GraphEdge(next_node="LLM", name="time_index")],
),
max_iters=self.config.num_timesteps - 1, # one Euler step per interval
outputs=[GraphEdge(next_node="vae_decoder", name="latents")],
),
GraphNode(
name="vae_decoder",
input_names=["latents"],
outputs=[GraphEdge(next_node=EMIT_TO_CLIENT, name="image_output",
output_modality="image")],
),
])
Declared outputs are conditional. A node’s outputs list is the set of edges that
the node can emit. What it emits on a given step depends on what its submodule produces.
new_token above is the clearest example. The LLM samples a token only when the request
requires text output, which is the case on the understanding path and on every decode
step. On the image-generation and editing paths, the same node still runs and writes the
KV cache, but samples no token, so it does not produce new_token. The edge is present
in the graph because understanding requests need it. Treat declared edges as the possible
outputs, and let the submodule decide which of them are produced on each step.
Choosing the walk per request. BAGEL’s transitions are driven by a schedule, unlike
those of Orpheus. The output modality is known in advance from the request’s
output_modalities. get_initial_forward_pass_args therefore builds a prefill
schedule, by iterating over the interleaved text and image inputs, and
get_partition_forward_pass_args advances through that schedule. It then transitions to
decode for text output, or to image_gen for image output. In think_mode, the
model first decodes a reasoning trace, and the EOS token then triggers the transition to
image_gen. These are the same two methods that Orpheus implements. BAGEL only encodes
a more complex state machine in them.
Step 4 — Submodules. Each node maps to a NodeSubmodule in bagel/submodules.py:
ViTEncoderSubmodule, VAEEncoderSubmodule, LLMSubmodule and
VAEDecoderSubmodule. get_submodule builds them on demand, so a worker that runs
only vit_encoder never allocates the 7B LLM. process_prompt tokenizes the prompt,
and also a system prompt in think_mode. postprocess selects an encoding by
modality: utf-8 text for decode, and PNG bytes for image.
Step 4a — Step declarations. BAGEL’s LLMSubmodule.declare_step is the most complex
declaration in the tree. Read it to see every advanced KVStep field in context:
pre_forksandpost_forks: guidance requires acfg_textstream forked frommain, and the fork time differs by walk. Inprefill_text, the branch must keep the context from before the text, so it forks before any planning or writing. Inprefill_vitandprefill_vae, the branch must include the image, so it forks at commit time, after this step’s writes are applied.combined_labels: in the batched-guidance denoise step, the conditional and unconditional labels are packed into one plan under a single combined label.PositionStep.pos_idsis keyed by that combined label, with the ids concatenated in the same label-major order.commit=False: the step plans and reads, but its writes do not become resident yet.cg_key_info: the step records whether guidance is enabled. This value matches theadditional_key_infoof the twodecodecapture configs, so each replay uses its own bucket. The submodule’scg_key_info()method reports the same value from the batch, because the engine leases a slot before the step is declared.
Step 6 — Config and disaggregation. This is the main benefit of the graph abstraction. The same model code runs on one GPU:
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]}
The same code also runs disaggregated across GPUs. Assign the same LLM node to
different ranks per graph walk: prefill on GPU 0, decode on GPU 1, and image generation on
GPU 2:
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]}
BAGEL also supports a CFG-parallel mode. When the config names the extra
LLM_cfg_text and LLM_cfg_img nodes, as in configs/bagel_cfg_parallel.yaml, the
model uses an image_gen_cfg walk instead. The loop body of that walk is a Parallel
of the three classifier-free-guidance branches, each on its own GPU, feeding a
combine_cfg node. The model code detects this mode only from the node names present in
the config, so the extra parallelism is enabled in YAML and requires no code change. One
model can therefore run in many physical layouts.
Worked example: Whisper#
Whisper (mstar/model/whisper/) is the reference for encoder-decoder models. It is the
smallest complete example of cross-attention over a fixed context.
It has two nodes. audio_encoder declares no resources. decoder declares six: its
own KV cache and attention, a second KV cache for the encoder context, a
CrossAttentionSpec over that second cache, a PositionSpec, and a SamplerSpec.
The PositionSpec exists only for the position counter. Whisper uses learned position
embeddings, so the planned ids index an embed_positions table instead of driving RoPE.
The SamplerSpec sets enable_repetion_penalty=False, because ASR transcription
decodes greedily.
The page counts show how to size a cache for a specific model instead of using the
default. Both caches use 128-token pages and hold WhisperModel.MAX_CONCURRENT_REQUESTS
(64) requests at full length, plus the sink page that every KVManager keeps out of
circulation. A sequence is at most max_target_positions (448) tokens, which is 4 pages
per request, so the self-attention cache has 64 * 4 + 1 = 257 pages, about 5 GiB for
large-v3 in bf16. The 2048-page default would use about 40 GiB. The fixed 30-second context
window is max_source_positions (1500) tokens, which is 12 pages per request held from
prefill to the last token, so the context cache has 64 * 12 + 1 = 769 pages, about 15 GiB.
Sizing the caches is only half of it: configs/whisper_large.yaml also sets
max_concurrent_requests: 64, so the conductor queues further requests instead of the
worker offloading decoding requests to admit them. WhisperModel.validate_config_yaml
refuses at startup a cap that either cache cannot hold after the YAML’s resources:
overrides, through mstar.model.utils.check_kv_pools_fit, which any model with KV caches
can call the same way.
The decoder node’s declare_step shows how a write-once context is expressed with spans.
Canary’s comes from EncoderDecoderNode (mstar/model/components/encoder_decoder.py);
Whisper’s declares its own, since it writes the context eagerly and captures prefill. The
context segments have a non-zero span in the prefill step that writes them, and a span of
0 in every later step. The commit in the prefill step converts the reservation into
resident pages that the later steps read.
Worked example: an image DiT on the diffusion scaffold#
Flow-matching image and video transformers (FLUX-family DiTs, Z-Image, Wan-style
video) share one graph shape, so M* ships it as a scaffold in
mstar/model/components/diffusion/; a model adds only its exact component
ports, its checkpoint remap and a few hooks.
Graph and Walks:
text_encoder ──text_embeds──▶ Loop("denoise_loop", dit) ──latents──▶ vae_decoder ──▶ EMIT image
vae_encoder ──ref_latents──▶ (image_edit only)
encode_text : text_inputs -> text_embeds (persist)
encode_image : image_inputs -> ref_latents (persist)
image_gen : Sequential[Loop(dit) -> vae_decoder]
image_edit : image_gen with ref_latents as an extra Loop input
latents is the loop-back edge; every other input is re-injected each
iteration. The step index is the engine’s loop counter
(fwd_info.dynamic_loop_iter_counts), so there is no host sync per step.
The dit node subclasses DenoiseLoopSubmodule (denoise_loop.py), which owns
the engine contract — seeded initial noise, per-request schedules, equal-key request
batching (can_batch), stacked forward_batched, check_stop at the request’s own
step count, the ragged-attention declare_step and one CUDA-graph bucket per
bucket_key with the Euler update inside the graph — and asks the model for:
class MyDenoise(DenoiseLoopSubmodule):
def bucket_key_for(self, fwd_info): ... # hashable: latent grid, text length, ...
def schedule_for(self, fwd_info, key): ... # FlowMatchSchedule (flow_match.py)
def seed_latents(self, fwd_info, key, gen): ... # [L, C] initial noise, seeded
def request_inputs(self, fwd_info, inputs, key): ... # per-row conditioning tensors
def num_tokens(self, key): ... # image tokens for the attention step
def capture_request_inputs(self, key, device): ... # dummy rows for graph capture
def denoise(self, engine_inputs, key, latents, timestep, sigma, sigma_next, **cond): ...
# optional: extra attention spans (a refiner over the image tokens alone, ...)
def attention_segments(self, key): return (("main", self.num_tokens(key)),)
bucket_key is whatever has to match for two rows to share a forward, and it is also
the CUDA-graph bucket key — shape, but equally a knob like whether CFG is on.
A multi-step scheduler that carries solver state between iterations (wan22’s UniPC, with
unipc_model_outputs and unipc_last_sample) names it in SOLVER_STATE; those
become loop-back edges alongside latents, seeded by seed_loop_back and returned
from denoise as a {name: tensor} mapping. A scheduler needing another per-step
scalar adds a STEP_SCALARS entry naming the attribute of its FlowMatchSchedule
to slice it from — the base stages that tensor on the device and slices it per step, so
nothing in the batching path has to change.
A model whose seed_loop_back depends on nothing but the bucket key and the request’s
seed sets PREFETCH_SEEDS = True (klein and Z-Image do): the base then draws the noise
when the request reaches the worker, on a thread of its own, and iteration 0 only copies
it to the device. Otherwise the CPU draw runs on the GPU thread at iteration 0, once per
joining row.
Every span a layer attends over must be declared with its own label (default: one
"main" segment per request); ragged_for(label) hands that span’s kernel to the
layers, which fall back to SDPA when no ragged resource is bound.
Shared pieces to build the components from: flow_match.py (shift schedules and the
exact Euler step), rope.py (multi-axis rotary embeddings), attention.py (joint
attention over packed tokens: SDPA eagerly, the ragged FlashInfer resource under graphs),
text_encoder.py (native Qwen3 hidden-state taps), autoencoder_kl.py (the KL VAE
family), image_io.py (latent packing, PNG) and lora.py (static LoRA merge at load,
also into fused q/k/v shards via FusedColumnLinear.shard_slice).
Two pieces swap a one-pass kernel in for what Inductor would emit, inside a compiled
transformer only; eager calls run the plain PyTorch formulation, which is what a port’s
bit-exact tests hold. modulation.ada_layer_norm is a block’s gated residual add,
LayerNorm and shift/scale modulation, with the modulation given per group of tokens
([B, G, D]: one group per sample, or per latent frame as in Wan2.2); Inductor reads a
LayerNorm row wider than 1024 twice. qk_norm_rope.rms_norm_rope is the q/k RMSNorm
across heads followed by the interleaved-pair RoPE, q and k in one launch. Both agree
with eager to bf16 rounding rather than bitwise. Wan2.2 is the first user;
test/modular/test_diffusion_dit_kernels.py holds them to the eager form.
On the API side DiffusionImageAdapter (mstar/api_server/openai/adapters.py) serves
/v1/images/generations and /v1/images/edits for any such model (size becomes
width/height, seed and other knobs pass through); register it under the
model’s registry key. The SDK exposes generate_image and edit_image.
Advanced: async partitions and streaming#
Models with a single partition can skip this section. The defaults in Model provide
one partition, named "default", that contains all walks.
Use several partitions when one stage must run asynchronously while another continues to produce data, for example an LLM feeding a vocoder, or a thinker feeding a talker. Override these methods:
get_partition_topology()declares the partitions and the streamingConnectionobjects between them, including achunk_policy_factorysuch asSlidingWindowChunkPolicy(window=..., stride=...).get_partitions()declares eachPartitionDefinition: its walks, its initial walk, and the partitions that produce data into it. Every walk belongs to exactly one partition.Route cross-partition tensors with
StreamingGraphEdge(next_node=..., name=..., target_partition=...)instead of a plainGraphEdge.
The consuming partition’s get_partition_forward_pass_args reads
incoming_connections, which provides token counts and a producer_done flag for
each stream from another partition, and uses them to decide when to run. A stream from the
client (below) has no entry there.
Keep a walk that consumes a stream to one node or one Loop on one node group, and move
later work into a downstream partition fed by its own StreamingGraphEdge. A consumer
whose every input is streamed runs again as soon as the next chunk arrives, and the
runtime and conductor count passes per worker graph, so the conductor refuses at startup
such a consumer sharing its worker graph with another top-level node or Loop, and a
stream-consuming walk split over worker graphs, unless every consumer also waits on a
non-streamed input and all the walk’s worker graphs run on the same workers. That input
must arrive once per pass (from the conductor, say); startup cannot check this.
A consumer whose walk should follow the producer’s progress, rather than a count fixed at
ingest, is producer-triggered: the producer decides the consumer’s walk. Set the same
consumer_walk function on every connection into the consumer, all from one producer
partition. The producer worker calls it for each row of a pass that emits on those edges,
with a ProducerWalkCtx: the walk that row ran (its own, even in a step that mixed
walks), the node that just completed, the iteration each enclosing dynamic loop is on,
and the producer’s full CurrentForwardPassInfo (step_metadata and all; the hook
runs on the producer). The row’s streamed items run under the walk it returns. The
context is reused across calls, so a hook must not keep it.
Each streamed item carries its assigned walk, and a chunk never spans two walks, so these connections need a
FixedChunkPolicy. A client stream cannot setconsumer_walk.The consumer’s worker switches walk before popping a chunk, and only once the partition has no pass under way. The conductor learns each pass’s walk from the worker’s
WORKER_GRAPHS_DONE.A walk the consumer enters on its own, with conductor inputs, takes effect when they arrive, so send them only once the stream has moved the consumer through the walk before it.
Qwen3-Omni’s Talker (_talker_walk) is the reference: Thinker prefill passes run
talker_prefill, the first decode iteration runs talker_last_prefill, and later
ones run talker_decode; the conductor feeds talker_decode its first input once
talker_last_prefill has run.
Streams from the client#
A model that listens while it runs (a full-duplex speech model, say) takes its live input
as a stream the client feeds. Declare it like any other connection, with the reserved
producer CLIENT:
from mstar.streaming.topology import CLIENT, Connection
Connection(from_partition=CLIENT, to_partition="Encoder", edge_name="audio_in",
chunk_policy_factory=lambda: FixedChunkPolicy(chunk_size=1))
CLIENT is not a partition: it has no walks and no worker, and it must not appear in
partitions. The node that lists audio_in in its input_names (with
consumes_stream=True) consumes it through a StreamBuffer and the connection’s
chunk policy, exactly as it would a stream from another partition. Its partition
self-triggers from the stream, so its forward-pass args send no inputs. That node must be
the only one, in any walk, that takes the edge; the conductor checks this at startup, and
that the model implements process_stream_chunk. A partition may take several client
streams (audio and video, say): each has its own buffer and its own end.
process_stream_chunk(chunk)turns each append the client sends into stream items, keyed by client edge name.chunkis aClientChunk(mstar.model.base):text;tensorsandinput_metadata, the append’s files loaded as for a request (audio_inputsand so on);streams, pertensorslist the client stream each file names (Nonewhere the client named none, so the model decides);model_kwargs, the append’s own; andrequest_kwargs, the opening message’s. RaiseValueErrorfor an append the model doesn’t take (the client gets a 400). An append of text alone that yields no item is refused rather than dropped, but the data worker can’t tell which items text sent with files yielded: refuse such text yourself if the model takes none. Keep it stateless; the consuming node keeps its own state, in its resources orrequest_state.Tensors
process_promptreturns under a client edge’s name are the stream’s first chunk, so a one-shot request (one uploaded file, then nothing) is a stream of one chunk.A client stream may declare limits,
Connection(..., limits=ClientStreamLimits(...))(mstar.streaming.topology), which the client-stream layer enforces before any of the model’s code runs:max_items, what the stream may carry over a call’s life, each item countingitem_size(item)(1 by default; a stream of variable-length chunks counts their frames or samples, named byunit). A call pastmax_itemsends with a 400 that names the limit, so the consumer need not check a session cap of its own. Declare the most the consumer can take: a deployment may lower a declared bound underclient_streams:(see Serving), but not raise it.A stream ends when the client ends it:
end_input: trueends all of the call’s streams, a list of names only those. The consumer getsproducer_donefor that edge and a final chunk, and a loop fed by the stream ends as any stream-terminated loop does. Over/generate/wsthe client appends and ends input explicitly (see Using a Server); every other route ends the input as soon as the request is submitted./generate/wsis on the Python frontend only, not--rust-frontend.The API server’s data worker is the stream’s producer. The conductor tells it the consuming node and its ranks once per call, when it dispatches the request, and the data worker sends each append’s items to every one of those ranks itself, and then the stream’s end with how many items it carried. Every rank gets each item whole, so a client stream can’t take a
sharding_config.shard_dimentry (startup refuses one). Once the consuming partition ends, later appends for its streams are answered as late.
Checklist#
[ ] mstar/model/<your_model>/config.py — config dataclass + resource-key constants
[ ] mstar/model/<your_model>/components/ — the nn.Modules + weight loading
[ ] layers name their resource keys (attn_key / kv_key / pos_key)
[ ] mstar/model/<your_model>/submodules.py — NodeSubmodule per node
[ ] prepare_inputs (set input_seq_len)
[ ] declare_step
[ ] preprocess / forward (+ forward_batched, can_batch)
[ ] mstar/model/<your_model>/<your_model>_model.py — Model subclass:
[ ] get_node_resources
[ ] get_graph_walk_graphs
[ ] process_prompt
[ ] get_initial_forward_pass_args
[ ] get_partition_forward_pass_args
[ ] postprocess
[ ] get_submodule
[ ] (optional) get_request_resource_configs
[ ] kv_labels, kv_layout from process_prompt (+ get_loop_caps) — with a paged KV pool
[ ] (optional) keys on a KVLabel + checkpoint_path — cross-request prefix reuse
[ ] mstar/model/registry.py — (module_path, class_name) in MODEL_REGISTRY
[ ] HF_MODELS too, if weights come from Hugging Face
[ ] configs/<your_model>.yaml — node_groups → ranks (+ resources: overrides)
[ ] mstar/cli/main.py DEFAULT_CONFIGS — else `mstar serve <name>` says "unknown model"
[ ] the client-example tuples lower down (cosmetic, user-visible)
[ ] docs/models.rst — one row in the registry-key table
[ ] (optional) mstar/api_server/openai/adapters.py — only for OpenAI-compatible routes
[ ] (optional) pyproject.toml extra — if it needs deps the base install lacks
[ ] (optional) async partitions if pipelined
Testing#
Validate the graph and the worker integration before you use real weights. The modular
tests run on CPU and exercise models in dummy mode, where get_submodule returns
None:
ruff check .
pytest test/modular/ # CPU graph/worker tests
pytest test/integration/ # requires GPU + weights
mstar-serve --config configs/your_model.yaml --port 8000
These modular tests are useful while writing a new model. They cover the parts that are easy to get wrong, and they run on CPU:
Test |
Covers |
|---|---|
|
The admit, plan and commit lifecycle driven by |
|
Stub resources for testing a submodule’s step declaration without a GPU. |
|
Capture-bucket keys, including |
|
Batch admission, and the paths for failed and backpressured requests. |
|
Behavior when a resource cannot admit a step. |
|
Offload and reload of a request’s cache state. |
Then send a POST /generate request and check the streamed output.
The fastest way to add a new model is to base it on the existing model that is closest to it. Use Orpheus for a streaming LLM with a codec, Whisper for encoder-decoder cross-attention, BAGEL for a unified understanding and generation model, and Qwen3-Omni for a full omni-modal model.