Inkling-Small on Gaudi 2¶
Inkling-Small is a 276B-parameter mixture-of-experts model (12B active) from Thinking Machines. It has no RoPE: attention adds a learned relative-position logit per head, and four short causal convolutions per layer carry state between positions. vLLM's Inkling model runs only on CUDA and ROCm, and its layers are not attention modules the HPU runner can give a KV cache. This port runs the published BF16 checkpoint, unconverted, text only, on one Gaudi 2 node at 256K tokens per request.
At a glance
- Checkpoint: thinkingmachines/Inkling-Small, Apache-2.0 (the model card links an acceptable use policy). BF16, 532 GB, loaded as published. Vision, audio and the MTP draft head are not built.
- Hardware: 8 Gaudi 2 cards, one node, TP 8. 262,144 tokens per request, 16 sequences, a KV pool of 2,560 blocks (327,680 tokens).
- Stack: Gaudi software 1.24.1, vllm-gaudi
releases/v0.30.0(058f107, vLLMced6857a), lazy mode. - Code:
ports/inkling-small/, recipe Inkling-Small. - Status: serving production traffic.
Why it needed a port¶
- vLLM's Inkling implementation uses Triton short-conv and QKVR kernels, FA4 relative attention and CUDA events in the MoE. None of it runs on HPU.
- Its layers are custom modules, not
Attention, so the HPU runner would allocate no paged KV for them. - The relative logit
rel[i, h, i - j]is a per-query, per-distance term. HPU attention has no hook for it except an explicit additive mask in prefill and the ALiBi position-bias slot in paged decode. - The short-conv state must survive chunked prefill and prefix-cache hits, which the HPU runner only supports for models it recognises as Mamba-like.
- vLLM's inkling parsers wrap a Rust extension that a Gaudi build of vLLM does not compile: the server starts and the first chat request fails.
What was built¶
42 layers, hidden 4096, 32 query and 8 KV heads of size 128. 35 layers attend within a 512-token window (rel extent 512), 7 are global (rel extent 1024, with a log-position scale on q and the rel logits beyond 128K). 256 routed experts top 6 plus 2 shared experts that every token uses, sigmoid selection with a bias, layers 0 and 1 dense. Logits are divided by 16 (muP).
Attention with a relative bias¶
Each layer computes rel = r @ rel_logits_proj from the R branch of a fused QKVR projection and stashes it for its
Attention call. A subclass of the HPU attention implementation adds it:
- Prefill: an additive FusedSDPA mask
[B, H, Lq, KV]that also carries the causal, window and padding masks (FusedSDPA refusesvalid_seq_lengthswith an explicit mask). Later chunks and prefix-cache hits prepend the cached context exactly as vllm-gaudi's_get_contextlays it out, and the query rows are processed in sub-blocks so the mask stays under a byte budget, with a graph break per sub-block; without the break, 42 layers of multi-GB masks accumulate into one lazy graph and run out of device memory near 100K tokens. - Decode: a per-block bias
[blocks, H, 128]in the ALiBi slot of paged attention. Key positions come from each block's ordinal in its sequence's (possibly window-truncated) block list, computed with a membership mask becausescatter_reducehas no HPU kernel. - Local-window prefill: local layers fetch and bias only the last 5 context blocks instead of the whole context. A CPU test proves the attention output identical to the full-context path over 40 random cases.
Short-conv state without a KV group¶
Each layer has four depthwise convolutions (kernel 4): on raw K and V, and on the TP-summed attention and MLP
deltas. Each needs the raw inputs of the previous 3 positions. They are stored as one snapshot row per attention
block id: slot b holds the state after the last position processed in block b. A full block carries the state at
its end, which is exactly what a prefix-cache hit on it needs; requests sharing a cached prefix read the same
snapshot; a request's partial last block holds its running state.
An earlier layout declared the state as a MambaSpec KV layer. vLLM's hybrid KV manager then gave it its own block
per 128 tokens from the same pool, which the model never reads. Half the pool was unusable, and because admission
needs the whole sequence to fit, a prompt over half the pool waited forever with no error. Now the attention page
is padded by the conv bytes (65,536 + 50,688 B per block per layer at TP 8), so vLLM's block count still budgets the
conv memory, and the port allocates the rows right after the runner allocates the attention cache. The scheduler sees
one full-attention group: every block is usable, and a prompt is either admissible or refused with HTTP 400. The
server refuses to start if the pool cannot hold one --max-model-len request.
Decode window¶
The vllm-gaudi runner gives interleaved sliding-window layers window / block_size decode blocks and adds one only
for gpt-oss, so Inkling's local layers saw 385 to 512 of their 512 keys in decode while prefill saw 512. The port's
runner patch adds the block for this model; the rel-bias mask cuts the window at 512 exactly. Measured on an earlier
build of the port: the mean gap between decode and teacher-forced prefill logprobs went from 0.089 to 0.034
(chained A/Bs: two loads of the same deployment, one variable).
MoE and parsers¶
Routing is computed in the model (sigmoid plus bias for selection, weights renormalised over the 6 routed and 2
shared experts) and handed to vLLM's fused MoE through a custom routing function. With INKLING_MOE_SINGLE_REDUCE=1
routed and shared outputs are summed per rank and all-reduced once per layer; the model refuses to start if FusedMoE
does not actually skip its own reduce, since a second reduce would scale the output by the TP size.
The inkling_py parsers are pure Python. The reasoning parser finds the end of a thinking block by scanning token
ids for the newest block marker; a fixed decode window missed the opener of long thinking blocks and leaked
<|end_message|> into content.
Correctness¶
Gates in tests/hpu/run_gates.sh:
plugin and KV layout checks, smoke (arithmetic, tool call, JSON mode, tool_choice required), greedy repeats,
reasoning and streaming splits without markers, chunk-boundary needles, needles to 128K, a 200-request logprobs
soak, prefix-cache hit against cold output, 16 concurrent needles and the HTTP 400 for over-long prompts, plus a
long tier with 200K and 250K needles, three 100K prompts resident at once and the long-prefill share.
The prefix-cache gate does not ask for byte-identical output. A hit prefills only the uncached tail, so its chunk shapes differ from the cold run, and BF16 rounding can flip a near-tie later in the output; two cold runs of the same 8 concurrent prompts also differ, from batch composition alone. The gate requires that every hit answers correctly and that any difference starts where the cold run's top two tokens were within 0.5 nats. A wrong conv state or block table breaks the answer itself.
Both tiers with this repository's install.sh on a stock releases/v0.30.0 image, launched as the recipe says:
every gate passed (results on the recipe page).
Recorded earlier on the deployed build of this port (the same model code, installed by an earlier bootstrap):
| Check | Result |
|---|---|
| Greedy output vs the previous release's port, 16 prompts x 192 tokens with top-5 logprobs | 16/16 identical, logprobs identical |
| GSM8K, first 50 | 47/50 |
| Needles at 128K, 200K and 250K, depths 0.1 / 0.5 / 0.9 | 9/9 |
| 32 random words copied verbatim from 250K tokens | 32/32 |
| Conv-state simulator: chunked prefill, prefix hits, shared prefixes, padding, block-boundary decode | 20/20 |
Performance¶
All on one 8-card node, BF16, warm graphs.
| What | Result | Measured as |
|---|---|---|
| Per-stream decode at 1 / 4 / 8 / 16 streams, port on releases/v0.30.0 | 43.9 / 38.8 / 31.2 / 21.7 tok/s | single run, production build |
| Same, port on v0.26.0 | 40.5 / 35.9 / 28.9 / 20.7 tok/s | different conditions: same harness and day, a different pod |
INKLING_MOE_SINGLE_REDUCE + INKLING_LOCAL_WINDOW_PREFILL |
1 stream 32 to 25 ms per token; 16 concurrent 121K prompts 2,701 to 927 s | chained A/Bs on one node, v0.26.0 build |
INKLING_STEP_HOIST |
1 stream 25 to 22 ms per token; 16 streams 202 to 248 tok/s aggregate | chained A/Bs on one node, v0.26.0 build |
| 16 streams decoding while a 250K prompt prefills (long-prefill share 0.5) | streams 30.8 tok/s before, 34.0 during; short requests start in 0.9 to 2.1 s | single run, production build |
| Cold TTFT at 32K / 128K / 243K | 9.7 / 46 / 138 s (first touch of a new length adds up to 14 s) | single run, production build |
The decode step is bound by op count and collectives rather than bandwidth: about 10.8K ops and 84 to 124 all-reduces per step at one stream, of which the conv streams and rel-bias index math are the largest share. That is what the step hoist and the single reduce remove.
What it taught about Gaudi¶
- An additive mask is the only way to give FusedSDPA a per-query term, and it cannot be combined with
valid_seq_lengths; the mask has to carry the padding itself. - Lazy mode accumulates everything until a graph break. Large per-layer transients need an explicit
mark_step. - The HPU runner keeps one block table for every layer. A second KV group (Mamba-style state) silently gets the wrong block ids or costs scheduler blocks the model never reads; padding the attention page is the safe way to budget side state.
Availability¶
The port is published in ports/inkling-small/:
plugin, runner patch, CPU tests and device gates. It installs into a stock releases/v0.30.0 image with
install.sh; the generic fixes it needs come from the fixes pack.