Configuration¶
Plugin Setup¶
To load the plugin, set the VLLM_PLUGINS environment variable before running vLLM:
spyre_inference names both the platform plugin and the general plugin: the first
activates the platform, and the second registers the OOT custom ops and the Spyre
model adaptations, including the Spyre Transformers backend (used for
model_impl="transformers").
Usage¶
You can then use vLLM as usual:
from vllm import LLM
llm = LLM(
model="ibm-ai-platform/micro-g3.3-8b-instruct-1b",
max_model_len=128,
max_num_seqs=2,
)
See the Examples page for more usage patterns.
Gemma-4: text-only use of a vision checkpoint¶
Every Gemma-4 repository carries a vision_config, so google/gemma-4-31B,
google/gemma-4-26B-A4B, google/gemma-4-E2B and google/gemma-4-E4B load as
Gemma4ForConditionalGeneration and build a vision tower — weights to load and graphs to
warm up that a text-only workload never runs.
To use one of those repositories for text only, pin its decoder architecture:
llm = LLM(
model="google/gemma-4-26B-A4B",
hf_overrides={"architectures": ["Gemma4ForCausalLM"]},
tensor_parallel_size=2,
)
That is also the configuration the tensor-parallel and compile e2e tests run these checkpoints under. A repository with no vision tower gets the override by default, so this is only needed for the multimodal ones.
Gemma-4: per-request image sizes¶
The vision tower is compiled per token budget at warmup, for the image size the engine runs
at: max_soft_tokens from --mm-processor-kwargs, default 280. A request can ask for a
larger size (560 or 1120) in its own mm_processor_kwargs: offline through LLM.chat or
LLM.generate, and online only when the server runs with --trust-request-mm-kwargs.
Each larger size needs its own budget, compiled at warmup, so they are opt-in through
encoder_cudagraph_token_budgets. To serve requests up to 1120:
vllm serve google/gemma-4-26B-A4B-it --tensor-parallel-size 4 \
--trust-request-mm-kwargs \
-cc '{"encoder_cudagraph_token_budgets": [64, 128, 256, 280, 576, 1152]}'
The list replaces the default budgets, so keep the small ones in it: 64 to 280 cover the
engine's own 280, 576 covers 560 and 1152 covers 1120. Each budget is rounded up to a whole
patch group. An image larger than the largest entry, as listed, is rejected with a 400
(VLLMValidationError offline) rather than compiled mid-request, and the encoder cache is
raised to hold one at that size. An entry above max_num_batched_tokens (512 unless
--max-num-batched-tokens sets it) needs chunked MM input, so --disable-chunked-mm-input
with it fails at startup.
Raising the engine-wide --mm-processor-kwargs '{"max_soft_tokens": 560}' instead makes
every image that size.
Decoder compile buckets¶
The body pads the packed token count to the next compile_sizes bucket, and warmup
dummies every bucket. The lm_head sits outside every body graph and compiles its own, so
it needs the same treatment: it projects one row per sampled request, a width that
would otherwise take every value in 1..--max-num-seqs as requests finish. Those rows
pad onto the same buckets clipped to --max-num-seqs, and warmup projects each width, so
no shape reaches the lm_head uncompiled. Pad rows are dropped before sampling.
Encoder / pooling compile buckets¶
Spyre compile is on by default (STOCK_TORCH_COMPILE, dynamic=False). Pass
--enforce-eager to disable it.
Everything derives from one number, R — the token budget. It is
--max-num-batched-tokens, capped at 2048 (the measured throughput argmax across
pooling models), floored at --max-model-len rounded up to a power-of-two multiple of
64, and capped again at what --max-num-seqs sequences of that length could carry, then
floored to a whole multiple of it so that every length divides R. --max-num-seqs is
then lowered to R / 64 if it was higher, since no batch wider than that fits.
- Body (Linear / LN): one shape,
Rrows. Every pooling step pads to it. Fixing it is what keeps the attention kernels keyed on sequence shapes alone. - Lengths: powers of two from 64 (one Spyre stick) up to
--max-model-len, rounded up to a power-of-two multiple of 64. Every length is then64 * 2^k, so each dividesRand each rectangle covers the body exactly.SPYRE_ATTN_QUERY_BUCKETSoverrides the ladder, rounded the same way. - Attention, rectangular path: for each length
L, one rectangleB = R / L. The runner pads every sequence toLand the batch toB, so Q/K/V are the grid: one reshape, oneF.scaled_dot_product_attention, one store, no data movement inside the layer. Taken whenevernum_seqs <= B. - Attention, ragged path: for a batch too wide for any rectangle, Q/K/V stay
packed and requests are split into groups — the requests sharing one padded
length. Each group is one fused gather/attend/scatter keyed on
(group width, extent), so a ragged step makes one kernel call per group rather than per request. Widths are powers of two up toB; a wider group is chunked into descending powers of two.
With --max-model-len 512 --max-num-seqs 32 --max-num-batched-tokens 2048 that is
23 shapes: one body, four rectangles ((64,32) (128,16) (256,8) (512,4), each
exactly 2048 rows), and 18 group pairs. At --max-num-seqs 4 the group family is
empty — no batch that narrow can miss the rectangular path — leaving five shapes.
Those counts assume the rectangular path is opaque; traced into the block graph (see
below), each rectangle is a block graph of its own rather than an attention kernel.
The choice is made once per step from the step's metadata, before the forward, and the
runner counts it in spyre_encoder_stats (rect_steps / ragged_steps). The
ragged path always runs behind the opaque attention op. When compiled and the head size
fills whole 64-element sticks, the rectangular path is traced into the block graph
instead, so each rectangle is its own block graph. In eager mode both paths stay
opaque, with no block graph; a compiled, unpadded sub-stick head size also keeps
both paths opaque, sharing one block graph. Pooling models such as granite-30m
pad their native 32-wide heads to 64 before construction.
Compiled pooling warmup runs one dummy at the body shape. With the rectangular path opaque, the first attention call in it traces every declared rectangle and group pair, against that call's own tensors (a Spyre tensor's device layout is part of its cache key). With it traced in, warmup adds one dummy per rectangle, plus one forced onto the ragged path (when any group is reachable) that traces the group pairs. Eager pooling uses one short dummy and always takes the packed path.
Example:
vllm serve ibm-granite/granite-embedding-125m-english \
--runner pooling --max-num-seqs 4 --max-model-len 512
Tuning buckets for padding¶
Bucketing trades warmup time for per-request padding. A request is padded up to the next bucket on each axis and the padding is masked out, so buckets far above your real shapes waste compute, while buckets that hug your workload cut that waste but add graphs to compile at warmup. Per-sequence attention pairs each query bucket only with KV buckets that fit its smallest real query length. Batched decode uses KV-length/num-sequences pairs; both recording paths also enforce the allocated page-capacity limit. Extra buckets can still multiply the recorded variants, so keep those lists short.
Decoder body (packed token count). Override the defaults with compile_sizes; the
platform clamps --max-num-batched-tokens to the largest entry. A decode-heavy run at
--max-num-seqs 8 rarely needs the full power-of-two ladder:
from vllm import LLM
llm = LLM(
model="ibm-ai-platform/micro-g3.3-8b-instruct-1b",
max_num_seqs=8,
max_model_len=2048,
compilation_config={"compile_sizes": [1, 8, 512]},
)
1 and 8 cover decode steps (one token per running sequence, up to 8); 512 is the
prefill bucket.
The default prefill bucket is 512 tokens, but larger ones compile. For prompts of many
thousands of tokens, a 1024-token prefill chunk shortens time to first token, on dense
models such as granite-3.3-8b as well as on MoE models such as gemma-4, where each
prefill step reads every expert's weights however many tokens it carries. Pass a larger
--max-num-batched-tokens to get one; the default buckets then keep 512 too, so short
prefills don't pad up to the larger one:
vllm serve google/gemma-4-26B-A4B -tp 4 --max-model-len 32768 \
--max-num-batched-tokens 1024 \
--limit-mm-per-prompt '{"image": 0, "video": 0, "audio": 0}'
Warmup takes longer, and for prompts under a few thousand tokens it gains little; decode
can get slightly slower. The example serves text only: a larger token budget also raises
the number of image-batch widths the vision tower is warmed at, which can run out of
device memory (#1302). At a long --max-model-len, a 2048-token bucket can push the
largest attention variants past torch-spyre's 60 s backend compile limit at warmup, so
they are left to compile on first use (torch-spyre#5250 makes that limit configurable).
Attention (KV length × query length). Set the buckets directly as comma-separated
lists. Each is clamped to its limit: entries above --max-model-len (KV) or
--max-num-batched-tokens (query) are dropped, and the limit is appended if missing, so
every schedulable length keeps a bucket.
export SPYRE_ATTN_KV_BUCKETS=256,1024,2048 # default: the SPYRE_ATTN_KV_LADDER preset (pow2)
export SPYRE_ATTN_QUERY_BUCKETS=1,512 # 1 = decode; 512 = prefill chunk
The default KV buckets are the powers of two from block_size; the denser presets below
union them with a further series. The ladder stays geometric rather than uniform because the
recorded set grows with both the KV and num-sequences axes, even after filtering, so each
extra entry costs warmup time. If your context never exceeds 2048,
dropping the higher entries removes variants from warmup at no serving cost.
Choosing a KV ladder¶
SPYRE_ATTN_KV_LADDER picks how finely the KV ladder subdivides, without having to write the
buckets out. The default is pow2. Every preset keeps the powers of two, so a KV length's bucket
can only move down relative to pow2, never up. The denser presets do not contain each other,
though: at block_size 128, 2600 rounds to 2688 under 8_5 but to 2944 under 4_3. Counts and
overpay below are for block_size 128, at the --max-model-len each column names:
| preset | spacing | buckets at 4096 / 16384 / 32768 | mean overpay at 4096 / 32768 |
|---|---|---|---|
pow2 (default) |
powers of two only | 6 / 8 / 9 | 39% / 39% |
8_5 |
8/5 ratio | 10 / 15 / 18 | 21% / 19% |
4_3 |
4/3 ratio | 14 / 20 / 24 | 15% / 13% |
9_8 |
9/8 ratio | 20 / 34 / 41 | 8% / 6% |
uniform |
odd sixteenths of --max-model-len |
13 / 15 / 16 | 14% / 14% |
Overpay is the bucket over the real KV length, averaged over every KV length above block_size,
so it describes a workload whose lengths are spread out. Warmup cost tracks the number of distinct
recorded kernels, which rises with the bucket count. The ratio presets put their buckets at fixed
token counts; uniform places them as fractions of --max-model-len, so its buckets move up with
the context while its count stays roughly flat. Short contexts that are not a power of two are the
exception: there the step rounds down to a whole block and adds a few buckets (16 at 2047 against
12 at 2048). Match --max-model-len to your real lengths before choosing it.
As a rule of thumb, a denser preset pays off with long KV lengths and large batches, because the
saving is a number of KV tokens per sequence per decode step; at short lengths or a few sequences
it buys little for its warmup. Once the block count exceeds one chunk of 32 // num_seqs blocks,
the batched-decode kernel also pads it up to whole chunks, which takes back part of a denser
preset's saving below 32 sequences. 8_5 is the recommended compromise, 9_8 the fastest when
warmup time does not matter, and pow2 the default because it has the shortest warmup.
If the KV lengths are known and clustered, SPYRE_ATTN_KV_BUCKETS beats every preset: put the
buckets where the lengths actually are. A workload that sits on one power of two gains nothing
from any preset.
A denser ladder for large batches only¶
A denser ladder pays off in proportion to the tokens it saves times the batch size, so its gain
concentrates at large batches. SPYRE_ATTN_KV_LADDER_LARGE_BATCH names a second preset that
batched decode uses once the batch's num-sequences bucket reaches SPYRE_ATTN_LARGE_BATCH_MIN_SEQS
(default 16, which with the default buckets means 9 or more sequences), while the per-sequence
path and smaller batches keep SPYRE_ATTN_KV_LADDER. Below the threshold the dense buckets
cost warmup without a measured gain. The second ladder is unioned with the first, so no length
rounds up further.
It is off by default, rejected at startup together with SPYRE_ATTN_KV_BUCKETS, which replaces
the ladder it extends, and ignored with a warning when the threshold is above the largest
num-sequences bucket. SPYRE_ATTN_KV_LADDER=8_5 with
SPYRE_ATTN_KV_LADDER_LARGE_BATCH=9_8 gets close to plain 9_8 at large batches for noticeably
less warmup.
With the batched-decode kernel enabled (SPYRE_BATCHED_DECODE=1, the default), warmup
records eligible KV-length × num-sequences combinations.
SPYRE_ATTN_NUM_SEQS_BUCKETS (default: powers of two from 1 to --max-num-seqs) is the
extra lever there, and the same keep-it-short advice applies.
KV cache size¶
The KV cache is sized by vLLM's own GPU worker code: after the weights load, it reads the
card's free memory (torch.accelerator.get_memory_info) and gives the KV cache what is
left of --gpu-memory-utilization (default 0.92) × total. The cache keeps every block
that memory pays for, usually far more than --max-num-seqs requests at --max-model-len
need; with prefix caching on, the spare blocks keep earlier prompts' KV for reuse.
Each Spyre layer allocates its own page per block, so the budget is converted into blocks at the device cost of all layers' pages. That is more than vLLM's shared pool counts when groups would overlay one buffer, as a Mamba hybrid's do. Every layer's K and V pages are one tensor each, and the Spyre allocator never splits a tensor across its 16 GiB memory regions, so a model with few layers gets fewer blocks than its budget: they must still pack into the regions. When memory runs out before a full batch fits, a warning names the knobs, and requests wait for pages instead of all running at full length.
The profile run is skipped, as the first forward on Spyre would trace the model before
the KV cache exists: a forward's activations come out of the 1 - --gpu-memory-utilization
headroom. Lower it if a forward fails to allocate. --kv-cache-memory-bytes skips the
measurement; --num-gpu-blocks-override replaces the block count it yields, so the
warning above checks the override instead. VLLM_CPU_KVCACHE_SPACE, which sets the
KV cache size on vLLM's CPU backend, is ignored, with a warning when it is set to a size;
setting it to a size next to --kv-cache-memory-bytes is an error.
FP8 KV cache¶
Pass --kv-cache-dtype fp8 to store the KV cache in float8 (e4m3), halving the bytes
each cached token takes:
It needs a head size that is a multiple of 128. Other quantized cache dtypes (fp8_e5m2, fp8_inc, nvfp4,
...) are rejected at startup. Since the KV cache is sized from free device memory (see
KV cache size), an fp8 cache fits twice the tokens of an fp16 one.
Attention still computes in the model dtype: pages are dequantized on read with the
checkpoint's per-layer KV scales, or 1.0 when it has none. The speedup grows with the number of cached tokens each decode step
reads, so it is small at short contexts; check accuracy against an fp16 cache before
relying on it.
The scales are compiled into the graphs as constants. A checkpoint whose layers carry different KV scales therefore compiles the transformer block and each recorded attention kernel once per distinct pair of scales, which lengthens warmup; one without scales compiles them once, as an fp16 cache does.
Offline engine in the calling process¶
At tensor_parallel_size=1, set VLLM_ENABLE_V1_MULTIPROCESSING=0 to run the offline
LLM engine in the calling process instead of a spawned EngineCore, which skips the
spawn and the second round of imports at startup. vllm serve always spawns EngineCore.
The engine then changes the calling process: vLLM freezes the garbage collector's heap,
caches its environment variables, seeds the global RNGs and leaves RANK/WORLD_SIZE
set, the worker sets torch's thread count, and the card stays open until the process
exits. Build an engine with tensor_parallel_size > 1 in a new process. Set
OMP_WAIT_POLICY=PASSIVE before Python starts: OpenMP reads it when it loads, so the
default the plugin sets only reaches spawned processes.
pyproject.toml Reference¶
The pyproject.toml includes several key build configurations:
Build Configuration¶
[tool.uv.extra-build-variables.vllm]
VLLM_TARGET_DEVICE = "empty"
CMAKE_ARGS = "--fresh"
[tool.uv.extra-build-dependencies]
torch-spyre = ["torch==2.14.1"]
These settings ensure:
- torch-spyre is built against the same PyTorch version (2.14.1) the runtime pins, so its C++ extension cannot drift in ABI
- vLLM is built with the empty backend — no device-specific C kernels. This avoids
the torch-version coupling of prebuilt CPU wheels and the dependency on
vllm._C(whose CPU-optimized ops we don't need; Spyre provides its own)
Source Repositories¶
The plugin pulls dependencies from specific Git repositories:
[tool.uv.sources]
vllm = { git = "https://github.com/vllm-project/vllm", rev = "..." }
torch-spyre = { git = "https://github.com/torch-spyre/torch-spyre", rev = "..." }
This ensures that torch-spyre and vllm are compiled/installed from source, instead of pulling pre-compiled wheels from PyPI.
PyTorch CPU Index¶
This ensures the CPU flavor of PyTorch is installed, as CUDA support is not required.