Nick CeruttiNotes

Inference Infrastructure / vLLM / Reasoning Models

Reasoning models emit two workloads. Your scheduler sees one.

Build notes from Meridian: why a reasoning request should be scheduled as two workloads with separate SLOs, how entropy replaces the token timer, and what broke when I ran the toolchain instead of reading the code.

By Nick CeruttiPublished Updated 8 min read

Key takeaways

  • A reasoning request is two sequences in one: N think tokens nobody reads, then output tokens a user is watching stream in. Continuous batching schedules both against the same inter-token latency target.
  • Meridian splits them into two queues. Output-decode drains the GPU batch first; think-decode fills the remaining capacity at a 2.5× batch budget.
  • KV eviction becomes three-tier: ThinkComplete goes first, ThinkActive second, and OutputCritical never evicts without firing an alertable counter.
  • Ending the think phase is an entropy decision, not a timer. EAT and RPDI signals inject </think> when the model signals convergence.
  • It attaches to vLLM through reversible attribute delegation — no fork, no patched source, no vendored scheduler.

Why is a reasoning request two workloads?

A reasoning model — DeepSeek-R1, Qwen3, Granite 3.2, o3-class — does not emit one homogeneous token stream. It emits a prompt, then a think block, then an answer:

[prompt] → <think> ... N reasoning tokens ... </think> → [output tokens]

Those two spans have opposite service characteristics.

PhaseUser-visibleLatency toleranceCost profile
Think-decodeNoHighThroughput-bound
Output-decodeYesZeroLatency-bound

A standard continuous-batching scheduler pulls both from the same priority queue against the same inter-token latency target. The consequence is quiet and expensive: output-phase latency — the only latency a user perceives — ends up constrained by think-phase batch dynamics that nobody is waiting on. The think phase can absorb far more batching pressure than it is being given, and the output phase pays for the mismatch.

The asymmetry is free performance if the scheduler can see it. Seeing it needs a token-stream state machine that knows which phase every request is in, at O(1) per token, and that tolerates the fact that reasoning models do not tokenize their think delimiters consistently — multiple start and end token IDs per model, nested <think> treated as noise rather than as a counter reset.

What dual-queue dispatch actually does

The scheduler holds two queues and drains them in a fixed order on every batch.

  • Compute an output token budget from the configured time-to-output-token target and the KV blocks currently available.
  • Drain the output queue first, capped by that budget and by memory.
  • Compute the think budget as floor(output_budget × think_batch_multiplier) — 2.5 by default.
  • Drain the think queue into whatever batch slots and memory are left.

The interesting part is not the ordering, it is the boundary condition. A request that emits </think> mid-batch has changed phase after the batch was already composed. Moving it to the output queue immediately means it can be dispatched twice in the same iteration. The transition has to register for the next schedule call, never the current one. That single rule is the difference between a scheduler that reorders and a scheduler that corrupts state.

The core takes slots and blocks, not bytes. Translating block counts into bytes needs the model's runtime block size, which is the worker's knowledge, not the scheduler's. Keeping bytes out of the core is what lets the same scheduler serve an MHA model and an MLA model without a branch.

Why KV eviction needs phase tiers

Once the scheduler knows the phase, the block manager should too. Every KV block is tiered as ThinkComplete, ThinkActive, or OutputCritical, and eviction walks that order strictly, LRU within each tier.

OutputCritical eviction is not forbidden — it is observable. Evicting one increments a dedicated counter and emits a warning carrying how many bytes were freed and how many were still needed. The target rate is zero. A non-zero rate means the deployment is undersized, and the operator learns it from a metric instead of from a user watching a stream stall.

Blocks only move down. A ThinkComplete block is never promoted back to ThinkActive, even though cross-attention makes that theoretically useful in rare cases. Prohibiting promotion keeps eviction stability something you can reason about in one direction; the cost is a rare double-eviction. That trade is written down as an ADR rather than left implicit in the code, which is most of the point of writing ADRs at all.

How do you stop a model overthinking without a timer?

Budget forcing — injecting </think> to end the reasoning phase — is usually done on a static token cap. A cap is a blunt instrument: it truncates hard problems and wastes tokens on easy ones.

Meridian uses two convergence signals instead.

  • EAT — end-of-thinking answer tendency (arXiv:2509.26522): the probability mass the model already places on think-terminating tokens. Tracked as an exponential moving average with a derived variance; when the variance collapses, the model has stopped changing its mind.
  • RPDI (arXiv:2603.14251): a redundancy signal that catches the overthinking regime, where generation continues without moving toward an answer.

Both run as CUDA kernels on a dedicated secondary stream, so the probe never lands on the critical decode path. Correctness is pinned against a NumPy reference: entropy agrees within 1e-3 across vocabulary sizes from 1024 to 152k over multiple seeds, EAT within 1e-4, and the entropy of a uniform distribution returns log(V) exactly. A hard cap survives as the last resort, and the emitted event carries which of the three reasons fired — because *we truncated it* and *it converged* are very different things to see on a dashboard.

A floor matters as much as a ceiling. The router will not fire a convergence signal before min_think_tokens, or a model that starts confident gets cut off before it has reasoned at all.

What running the toolchain found that reading the code did not

Three passes of static review had already gone over this code and fixed real things. Then I installed the toolchain and ran the full suite for the first time, and it found four more that review had not.

  • A plugin that read correctly and did nothing. The vLLM plugin classified request phase using a Python set of IDs that had exited the think phase. That set was declared as a class attribute — shared across every plugin instance — and, more fatally, nothing ever added to it. Every request therefore classified as think-decode, and the reorder was inert. The code looked right on the page in a way it never was at runtime.
  • Two correct tests that were wrong together. The EAT convergence test and the RPDI overthinking test shared one config. Under a constant calm EAT input the EAT variance also collapsed, so *converged* fired before *overthinking* ever could, and the RPDI test was quietly verifying the wrong path. The fix was a config per test, with the EAT threshold set unreachable in the RPDI case. A test config that enables every mechanism verifies none of them in isolation.
  • An assertion encoding my mental model. An eviction test expected a full tier to be cleared. evict_for frees exactly the deficit and stops — which is correct, and the opposite of what I assumed when writing the assertion. The test was wrong, not the code.
  • A build that needed a C toolchain on a path advertised as dependency-free. The non-CUDA path compiled a C shim, which pulled in cl.exe and the Visual Studio environment on Windows. Replacing it with Rust stubs behind #[cfg(not(feature = "cuda"))] kept the ABI identical and made the crate build anywhere, with no nvcc and no C compiler. The portability was a side effect of fixing a build failure.

None of those are visible on the page. All four are visible in the first thirty seconds of a real run. Static review verifies intent; execution verifies behaviour; the gap between them is where the bugs that reach production live.

What is verified, and what is not

The phase-router state machine, dual-queue dispatch, three-tier eviction, the CPU entropy backend, plugin attach/reorder/inject, the disaggregated block surface, the NIXL wire protocol against a mock, and the synthetic benchmark harness all run in CI without a GPU. The CUDA entropy and EAT kernels are verified on a GPU runner against the CPU oracle.

Not verified: the real-vLLM benchmark path, and any throughput or latency claim. Meridian ships a synthetic replay harness with Poisson arrivals and a calibrated per-token decoder precisely so the A/B comparison is reproducible in CI — but a decoder calibrated for bf16 Qwen3 on an H100 measures scheduler *behaviour*, not the speedup you would see on your hardware. There is no published speedup number, because I have not earned one.

Meridian is a proof of concept built in public to demonstrate a scheduling thesis. It is Apache-2.0, releases carry SLSA Level 2 provenance, supply chain is audited on every commit, and the ADRs explain each load-bearing decision — but it is not software I maintain on anyone's behalf.

Frequently asked questions

What is inference-time compute scheduling?

Scheduling policy applied to the token-generation phase of a request rather than to admission or routing. For reasoning models it means deciding, per decode step, which requests get batch slots and KV memory, and when to terminate the reasoning phase — as opposed to deciding which GPU or replica handles a request.

Does Meridian require forking vLLM?

No. It attaches to the running scheduler through reversible attribute delegation: the plugin wraps the existing scheduler object, delegates every unhandled attribute to it, and reorders only within the batch vLLM already composed. No vLLM source is modified, and detaching restores the original behaviour.

Which reasoning models does phase routing support?

Any model with identifiable think-boundary tokens. Per-model token boundary configs ship for DeepSeek-R1, Qwen3 and Granite 3.2, with multiple start and end token IDs per model because reasoning models do not tokenize their think delimiters consistently. A model with no reasoning phase transitions straight from prefill to output-decode.

Does the scheduler preempt requests?

No. It reorders within a batch vLLM has already decided — it does not admit, preempt, or drop. That is a deliberately conservative boundary chosen to survive vLLM API drift between releases, and it is documented as a known limitation rather than presented as a finished design.

Is Meridian production software?

No. It is a proof of concept built in public. The engineering hygiene is real — CI, ADRs, supply-chain audit, SLSA Level 2 provenance — but there is no maintenance commitment and several paths are wired without GPU validation.

Sources & references

Related notes

Written by Nick Cerutti, AI infrastructure architect. I build the layer that makes agents and inference workloads run reliably in production — multi-tenant agent platforms, inference serving, governance and evaluation. Get in touch or book a 15-min call.