Multi-backend and multi-model: PocketLLM against vLLM 0.30 and SGLang 0.5.20¶
Date: 2026-09-26
PocketLLM baseline: master 8162937
Compared against: vLLM v0.30.0 (released 2026-09-22), SGLang v0.5.20 (released 2026-09-18), and
the local 2080 Ti fork vLLM-2080Ti-Definitive-v0.1.15, whose upstream base is vLLM 0.21.0.
This document exists because the two comparisons already in this directory are no longer against
current engines. PocketLLM vs vLLM vs SGLang was written
against the local fork, which is nine upstream releases behind; the pre-Phase-1 comparison
predates the pocketllm control plane altogether. And both of them catalogue features — batching,
prefix caching, speculation — while the two questions this repository keeps running into are
architectural:
- How does a second hardware backend get added, and what does it cost?
- How does a second model get added, and what does it cost?
So this page compares on those two axes, against what vLLM and SGLang actually are in September 2026.
Repository state. This page compares
masterat8162937, when PocketLLM still carried the whole multi-backend and multi-model stack. That code has since been cut down to the single-card, edge-and-mobile scope: one runtime survives here, thexing4backend, and the C++ engine (cpp_engine/), the native sources (src/csrc/), the multi-card runtimes (v41,mimo,torch) and theBatchSchedulermoved to sibling repositories — the C++ engine to the relic-engine archive, the multi-card runtimes to RelicLLM, and the native kernels to relic-core. The comparison below is kept as a snapshot of that state, and the references it makes tocpp_engine/…,pocketllm_cppand the five adapters describe the archived engine and the RelicLLM runtimes rather than this checkout. §8's decision — one request lifecycle, one scheduler — now describes RelicLLM's architecture, not this one.
Evidence standard. Every claim about this repository is a source citation against 8162937 and is
reproducible with the grep or test named beside it. Every claim about vLLM or SGLang is a read of the
v0.30.0 / v0.5.20 tags or their release notes, cited by path or issue number, and not measured
on this hardware — the numbers in those engines' own blogs are theirs, taken on hardware this machine
does not have. Where a claim about performance is a difference, this page says which two figures were
subtracted and how; where it is a single reading, it says so.
1. The difference in one paragraph¶
vLLM and SGLang are one generic data plane with a per-hardware plugin seam: a common scheduler,
KV-cache manager and model-runner framework, plus an out-of-tree Platform/plugin interface that lets
a vendor supply kernels, attention backends and communicators for a device the core does not know.
PocketLLM is the inverse: N per-model data planes — five Python runtimes and two C++ engines, each
hand-written against its own checkpoint — sharing one Python control plane, and a build-time backend
choice that makes CUDA and Ascend mutually exclusive in a single binary.
Both designs pay for their choice on exactly the axis the other one optimizes:
- vLLM's platform plugins leak. The vLLM RFCs say so in their own words: #51212 ("Model Runner V2 Pluggable Design") records that "each backend ends up copying the entire GPU model runner and maintaining it independently… ~8000 lines", and #45133 records that vLLM Ascend "now has rewritten over 55 Triton kernels… the current approach uses monkey-patching". A vendor cannot be a backend without forking a runner and patching kernels.
- PocketLLM's per-model planes duplicated.
mimo_backend.py(now in RelicLLM) andxing4_backend.pyshared a byte-identical_decode(difflibratio 1.00), three worker scripts in the backend factory were the same ~45-line program three times, and there were four independent prefix-cache implementations in the tree. Adding a model meant writing a sixth stack.
The 2026 counter-current matters here: vLLM #42770 ("Changes in vLLM Model Development") is vLLM
arguing that "we pursued the unrealistic ideal of a single model definition that works well on every
hardware. In practice, each hardware backend benefits from its own model implementation" — and the
newest frontier models have moved to vllm/models/<model>/{common,nvidia,amd,xpu,cpu}/ with a
platform-dispatching __init__.py. The generic-model camp is splitting its own position into "generic
for the tail, per-vendor for the head". That is, in effect, a statement that PocketLLM's core bet is
defensible; what PocketLLM lacks is not the bet but the seams.
§8 is the decision this page records, and it splits the two axes. Model computation stays
per-model — that is the part both competitors are moving back toward. The request lifecycle and the
scheduler become one implementation over the narrow InferenceEngine contract that existed in
cpp_engine/include/inference_engine.hpp (now in the relic-engine
archive), driving every runtime, C++ or Python. The short version is: N architecture families over
one lifecycle, not one runtime for twenty checkpoints.
The cut to single-card scope happened before that decision was implemented, so what §8 describes as the plan is now RelicLLM's architecture rather than this repository's: the five lifecycles and the three schedulers moved out with the runtimes they served.
2. Layer by layer¶
| Layer | PocketLLM 8162937 |
vLLM v0.30.0 | SGLang v0.5.20 |
|---|---|---|---|
| Control plane | pocketllm/: EngineArgs, LLM/AsyncLLM, HTTP server, supervisor, 5 backend adapters (the 5 have since moved: 4 to RelicLLM, 1 — cpp — to the archive) |
vllm/v1/engine + EngineCoreClient (Inproc/MP/DP), API server and engine core in separate processes over ZMQ |
TokenizerManager → Scheduler → TpModelWorker → DetokenizerManager, all over ZMQ; optional Rust front end |
| Scheduler | one BatchScheduler for the native server; pocketllm serve --backend cpp bypasses it by default (see §6.1) — the C++ engine and its scheduler are now in the relic-engine archive |
token-budget scheduler, no prefill/decode phase machine, chunked prefill always on, recompute-only preemption, --watermark |
Scheduler + PrefillAdder, overlap scheduler on by default, retract-then-recompute preemption, LPM/HRRN/priority policies |
| KV | 16-token blocks in a pool; cross-request sharing only in QwenEngine, block-aligned; per-model layouts differ (paged GQA vs ring+compressed vs recurrent state) |
KVCacheManager → coordinator → per-type manager → BlockPool; several cache groups with one page size; Mamba/SWA/CrossAttention/Sink managers; pluggable KVCacheSpecRegistry |
one unified radix tree for all models since 0.5.19, per-component tombstones and cascading eviction (Full > SWA > Mamba) |
| Prefix cache | four implementations: src/models/prefix_cache.py (v41+MiMo), src/models/xing4_0/prefix_cache.py, src/runtime/prefix_snapshot.py, and QwenEngine's global store |
one, in the block pool: chained sha256, --prefix-match-unit, deterministic NONE_HASH since 0.29, on by default incl. Mamba since 0.28 |
one radix tree, with 0.5.20's SWA branching-point caching |
| Execution | per-model hand-written loops in C++ and Python; no IR (the C++ half is now the archived engine) | Model Runner V2 (default since 0.29; MRV1 removal targeted v0.32): persistent rows decoupled from input tensors, no CPU sync in the loop, Triton-native input prep, UVA | ModelRunner with eager / decode_cuda_graph / prefill_cuda_graph runners |
| CUDA graphs | zero in the archived C++ engine (grep -rn cudaGraph cpp_engine → 0 hits); one Python path (src/models/xing4_0/graphs.py), measured 3.84× — the Python path is the one that survives here |
FULL_AND_PIECEWISE by default at -O2, BatchExecutionDescriptor bucketing, per-backend AttentionCGSupport, GC frozen during capture |
per-phase config; breakable graphs are the CUDA prefill default; decode stays full |
| Collectives | 2 all-reduces/layer, serial with compute except Qwen prefill; CUDA = stock NCCL only; Ascend has a hand-written IPC all-reduce and a device-side arrival wait, both default (the single-card runtime here has no collective) | NCCL symm-mem → FlashInfer → CustomAllreduce (IPC) → PyNCCL → torch; FlashInfer AR default for TP but gated to sm90+ | custom_all_reduce_v2 (one-shot push/pull, two-shot, multicall) with NCCL fallback; MSCCL++ |
| Op surface | 86 + 148 CUDA ops declared in headers, 371 launch sites, all of them under backends/cuda/kernels/; engine/ and core/ contain zero launches; Ascend mirrors 45 — all of it now in relic-core and the archive |
CustomOp / PluggableLayer / direct_register_custom_op; csrc/ ~107k lines |
BaseFusedOp with forward_<kernel backend> × forward_<platform>; AOT kernels in python/sglang/kernels/aot |
| Model layer | 7 hand-written runtimes under src/models/ + 2 in C++, registered by architecture string (one runtime — src/models/xing4_0/ — survives here; the rest moved out) |
registry (368 archs) for the tail, vllm/models/<model>/<vendor>/ for the newest, plus a transformers backend that reached native speed (#47187) |
registry + a ~200-line if/elif in configs/model_config.py; ~240 model modules |
| Second hardware backend | POCKET_BACKEND=cuda\|ascend at configure time; four CUDA-only TUs excluded on Ascend behind throwing stubs (the build lives in the archived engine) |
out-of-tree plugin package (5 entry-point groups, one platform per process); Ascend, Gaudi, TPU, Metal, OpenVINO all OOT | out-of-tree platform plugin with an explicit [Active]/[Planned] contract; NPU in-tree with Codeowners and a separate kernel library |
| Second model | a new run-time stack per checkpoint family | a model file + one registry line (dense), or a per-vendor subtree (frontier) | a model file + two registry edits; a dense PR was measured at +881/−6 lines |
3. Multi-backend, in detail¶
3.1 What PocketLLM has that the others do not¶
The core/ / engine/ / backends/ split was enforced, not aspirational — in the C++ engine, now
in the relic-engine archive: check_layering in
cpp_engine/CMakeLists.txt failed the build if include/, core/, engine/ or backends/api/
pulled in a vendor SDK header — and for engine/ it applies a second test, failing on a vendor
runtime entry point too, because an include check alone would miss a stray cudaMalloc that
compiles only because some other header pulled the SDK in transitively. The invariant holds
empirically — zero <<< launch sites in engine/, core/ and include/, all 371 of them under
backends/cuda/kernels/ (22 files). The op contract is coarse-grained and
vendor-neutral by construction
(void* stream on all but two of 93 declarations in cuda_ops.hpp). That is a cleaner kernel seam
than either competitor has: no vendor in the vLLM or SGLang trees gets that guarantee, which is why
both of them end up monkey-patching (vLLM #45133) or accepting a per-vendor model subtree.
Two consequences worth stating plainly, because they are the reason the Ascend work was affordable:
core/(loaders, tokenizer, HTTP server, sampler, metrics, block pool) is shared verbatim across both backends — ~7.2k lines that did not have to be written twice.- Parity is testable per operator:
test_*_parity.cppholds the Ascend implementation to the CUDA one, which is how the KDA/GDN divergence class of bug was caught before serving.
3.2 Where the seam stops¶
This subsection and §3.3 describe the C++ engine as it was at 8162937; that engine is now archived
in relic-engine, and its build (backend selection,
check_layering, the Ascend stubs) is documented there. Build-time backend selection is the
documented decision (route A: "a single engine plus a device runtime abstraction, not per-backend
engine forks") and this page does not argue against it. The problem is that the abstraction's
coverage stops well short of "a second backend is a port":
| CUDA | Ascend | Gap | |
|---|---|---|---|
| Ops declared for the Qwen FP16 path | 148 | 45 | 103 ops missing |
| Engines buildable | 2 (QwenEngine, PersistentEngineAdapter) + 2 drafters |
1 (QwenEngine) |
deepseek_v4_engine.cpp, dspark_engine.cpp, qwen_dspark.cpp, qwen_dflash2.cpp excluded |
| Tenants of those engines | the whole model set | Qwen FP16 only, and only in the C++ engine | the Python plane (--backend v41/mimo/xing4/torch, four of which are now in RelicLLM) has no NPU path at all |
| Collective | stock NCCL (ncclAllReduce) |
hand-written IPC all-reduce + device-side arrival wait | the CUDA side has no equivalent optimization |
| Layering check | all four guarded directories enforced, engine/ with a symbol test too |
same | — |
The layering row used to be the one with leverage, and it is now the one row with no gap: the check
covers all four guarded directories and applies the symbol test to engine/ (added with the
vendor-neutral device runtime, #138), so a model cannot reach the device from engine/ without
going through backends/api/device_runtime.hpp — the build says so. What remains is the second row.
engine/deepseek_v4_engine.cpp names CUDA kernels directly in ~120 places, which is why
engine/backend_unimplemented_ascend.cpp (317 lines of throwing stubs) has to exist at all: a CUDA
symbol referenced from any object in the link must resolve even when its branch is unreachable. Those
stubs shrinking to what is genuinely model-specific is Ascend-side work — implementing the
cuda_ops.hpp contract for the four excluded translation units — and it cannot be done or verified
from a host with no /dev/davinci* and no CANN.
3.3 What the competitors' plugin seams cost, and what they buy¶
vLLM's plugin surface is five entry-point groups; a platform plugin returns a class qualname and must
set worker_cls, get_attn_backend_cls, get_device_communicator_cls. What it buys: an accelerator
vendor never touches the core, and the ecosystem shows it (Ascend, Gaudi, TPU, Metal, OpenVINO, plus
vendor forks). What it costs, in the maintainers' own accounting: the runner is copied (#51212), the
attention enum is closed so a plugin can only override a member or use CUSTOM, quant configs are
registered by an import the user must perform, and registration failures used to be swallowed silently
(#48277). Two further facts are worth internalizing: vLLM's Ascend plugin's stable line is still
vLLM 0.23.0 — the plugin ecosystem lags the core by design, because each release of vLLM is a
compatibility event — and --model-impl transformers now reaches native speed (#47187), which is
the strongest existing evidence that a well-specified generic interface can avoid a runtime tax.
SGLang's contract is the most honest document in the field: its plugin guide marks each method
[Active] or [Planned], states that planned ones "will NOT take effect until the core is migrated",
and says outright that "device-family branches remain outside the interface" and that vendors should
"report that gap". Its NPU path is in-tree with named Codeowners and an external kernel library
(sgl-kernel-npu), which is the opposite trade from vLLM: less plugin purity, more likely to work.
The contrast worth copying is llama.cpp, which is the only engine here with a fully pluggable
backend: ggml_backend_reg_t / ggml_backend_dev_t, a runtime .so search honouring
GGML_BACKEND_PATH, and a score function that lets a device bid for placement. Sixteen backends exist
under ggml/src/. What it costs is visible too: ops must be expressible in the ggml IR, the quant type
set is a compile-time enum with a static traits array (ggml_type_traits[GGML_TYPE_COUNT]), so a new
quant format is a core edit — which is precisely the position PocketLLM is in with GGUF, except
PocketLLM chose that position deliberately and gets the whole low-bit family with it.
Reading for PocketLLM. The kernel seam is the one that pays. PocketLLM already has it and should
extend it rather than replace it: the missing coverage is op count on Ascend (a kernel-task problem,
not an abstraction problem) and the four CUDA-only TUs in engine/ (a layering problem the plan
already scoped). A runtime plugin mechanism is not needed for a two-backend, build-time-selected
engine — but a capability report per backend is, because the Python layer currently recomputes it by
hand three times (see §4.3).
4. Multi-model, in detail¶
4.1 How a model arrives in each engine¶
| Mechanism | Measured cost of a new dense model | Cost of a new frontier model | |
|---|---|---|---|
| PocketLLM | new runtime stack under src/models/<name>/ + a registry key + an adapter |
not measured; the engine is reused when the architecture matches (Bonsai reuses Qwen's) | src/models/ per-model directories run 1.7k–9.4k lines |
| vLLM | registry line + model file | llama.py 601, qwen3.py 340 lines |
per-vendor subtree; Kimi-K3/GLM-5.3 class models are a project |
| SGLang | model file + EntryClass + a config if/elif arm |
+881/−6 across 9 files for one recent dense model | GLM-5.3-Flash was 103 files / +7,740 (−558) |
PocketLLM's genuinely efficient case is the one the new-model roadmap already exploits: Bonsai
reuses Qwen3.8-27B's runtime field for field because the GGUF declares qwen35. That is per-model
runtime reuse by architecture, which is the right unit — and it is exactly what a ModelRuntime
spec would make explicit rather than incidental.
4.2 The five-stack problem, quantified¶
Serving entry points end to end as of 8162937:
| # | Entry | Processes | Who runs the forward | HTTP server |
|---|---|---|---|---|
| 1 | pocketllm serve --backend torch |
rank 0 + N−1 | DeepSeekServingEngine queue → src/models/deepseek_v4 |
Python (pocketllm/server/openai.py) |
| 2 | pocketllm serve --backend cpp |
1 or rank0+N−1 | pocketllm_cpp.QwenEngine, token by token from Python |
Python |
| 3 | pocketllm serve --backend v41 |
rank0 + N−1 | src.models.deepseek_v4_1.generate |
Python |
| 4 | pocketllm serve --backend mimo |
rank0 + N−1 | src.models.mimo_v2.generate |
Python |
| 5 | pocketllm serve --backend xing4 |
1 (no TP path) | src/models/xing4_0/generate.py |
Python |
| 6 | pocketllm_engine --serve --tp-world 4 |
4 shell-launched processes | QwenEngine or PersistentEngineAdapter |
C++ |
| 7 | torchrun -m src.server.openai |
N | src/models/deepseek_v4.generation |
Python (its own server) |
Five request-lifecycle implementations, three schedulers (BatchScheduler in C++,
DeepSeekServingEngine's bounded queue with max_running_requests default 1,
PDScheduler's phase-resource switching), four HTTP servers, and five IPC mechanisms (NCCL, the C++
CmdChannel unix socket, the per-worker work_bell doorbell, broadcast_object_list, and the
Python sidecar pipepair for chat templating).
Rows 1, 3, 4 and 5 now reach the one BatchScheduler behind --enable-batching, each declaring
itself width 1; row 1's queue is the fallback the other three do not have, because it predates the
scheduler rather than sharing it. See §8.3.
Rows 6 and 7 have since been deleted. The table is a snapshot of 8162937 and is left as one,
but the two entries that carried their own front end are gone
(#447): the C++ --serve server, and the
torchrun -m src.server.openai launcher whose runtime half moved to
src/models/deepseek_v4/serving.py. That is what removes the C++ HTTP server and the sidecar
pipepair from the counts above, and what makes the shipped cpp backend a client of the engine
rather than a second server in front of it. Rows 1–5 are now the whole list, and they are one
command with one front end.
And then the cut removed rows 1–4 as well. Only row 5, --backend xing4, is left in this
repository: torch, cpp, v41 and mimo are gone from the tree, the four adapters with them, so
the duplication counted below is now a record of what was removed rather than a description of the
checkout. What remains is one adapter — pocketllm/backends/xing4_backend.py — and one runtime under
src/models/.
The duplication was measurable at file granularity (all of it now moved out with the runtimes it was between):
mimo_backend.py(1,006 lines) vsxing4_backend.py(776): a method-level diff found ~288 lines of same-named methods at ≥0.5 similarity, and_decodewas byte-identical (difflibratio 1.00, verified).streamis a ~65-line clone differing in a thread name._publish_cache_metricsexisted three times (v41, mimo, xing4) with the same nine keys. Now one body inBackendBase— see §8.3.- The three
run_workerscripts in the factory were the same program three times. Now one program plus aWorkerSpecregistry — see §8.3. _IGNORED_OPTIONSwas written out three times.- OpenAI request parsing/validation existed twice, in two languages:
pocketllm/protocol/(575 lines) andcpp_engine/core/openai_request_fields.cpp+openai_stop_strings.cpp+json_constraint.cpp(1,296 lines) — the second copy is in the archived engine.
4.3 Three copies of "what does this backend support"¶
BackendCapabilities was declared once but computed by hand per adapter, and the rejection logic
was duplicated: _reject_unsupported_* appeared four times in the factory, each adapter kept its own
_IGNORED_OPTIONS frozenset, and supports_prefix_caching was set to "the store exists" rather than
"the flag is on" (xing4 back then, and v41). The cut collapsed this to one declaration in
pocketllm/backends/capabilities.py, consumed by the dispatcher — the shape this paragraph asks for. vLLM's equivalent is the AttentionBackend
capability set (supports_head_size/dtype/kv_cache_dtype/block_size, is_mla/is_sparse/is_ssm,
supports_sink/sliding_window/batch_invariance, and — new in 0.30 — supports_dcp()/supports_pcp()),
where an implementation that does not declare a capability fails at backend selection instead of
after weight load. That is the shape worth borrowing: one declaration per runtime, consumed by the
dispatcher, instead of a validator per adapter.
4.4 Verification, which is where this bites hardest¶
docs/models/README.md defines a status ladder and requires evidence for each rung. The test suite
does not enforce it:
- CI runs no tests. The two workflows build a wheel and a docs site. Everything below is a local convention only.
- The false-passing
tests/test_cpp_backend_batching.pyand the fixture-binary concurrency test it sat beside were C++-engine tests and went with the engine to the relic-engine archive. What replaced the gap it left istests/test_served_path_golden.py, which pins "this checkpoint served through this entry point produces these tokens" against a recorded fixture. - The suite's baseline failure set is now empty (
tests/baseline_failures.txt): every test this host collects and runs passes, and the set is diffed rather than counted so a new failure shows up even when another is fixed.
For a repository whose model pages make per-model claims at four levels of evidence, the served-path
fixture is the load-bearing one, and it is the piece that landed after this page was written. vLLM
and SGLang gate on generation tests registered into CI suites; PocketLLM's equivalent is
tests/test_served_path_golden.py plus a bench_* script a human reads.
5. Where the divergence is right, and should be defended¶
These are the places where "just make it like vLLM" would destroy the thing that makes this engine worth having. The repository's own rules say not to unify vendor kernels; this section is the same argument applied to the newer evidence.
- Per-model kernel-level specialization. TensorRT-LLM's own DeepSeek-V4 blog measures +64.5% end-to-end on one model generation from model-specific work alone (984 → 1,618 tok/s/GPU on GB300), with operator-level gains of 1.2–2.3×. vLLM's #42770 is the same conclusion arrived at from the other direction. PocketLLM's GGUF/DP4A/MMA prefill kernels, its MLA and hyper-connection ports, and its IQ4_NL codebook are this camp's work.
- GGUF and low-bit first. vLLM removed GGUF from the tree (
vllm-gguf-plugin, described by its own docs as "highly experimental and under-optimized"); bitsandbytes left in 0.28; PagedAttention was deleted in 0.25. SGLang went the other way (in-tree GGUF loaders for specific 2026 models, plusExpertPack). PocketLLM runsQ2_K,IQ2_XXS,IQ3_XXS,IQ4_NL,IQ1_M,PTQ1_0and GGUF tokenizers. That is a capability the generic engines do not have, and it is the capability this hardware needs: a 22 GiB sm_75 card with no bf16, no FP8 and no FP4 can only run some of these checkpoints through low-bit GGUF at all. - The C++ engine as a latency product. Two engines behind one Python face is unusual, but the measured head-to-head against the local vLLM fork (1.019–1.093× decode, 0.957–1.023× prefill at TG=128, TP4) and the concurrency scaling (2.14×/3.61×/4.68× at 2/4/8 against vLLM's 1.52×/2.64×/3.82×) are the reason the C++ path exists. That engine is now archived in relic-engine.
- Build-time backend selection. A runtime vtable in the decode hot path on a card whose decode is already close to bandwidth-bound is a cost with no benefit at two backends. Keep route A.
- Resident-vs-offload decisions per checkpoint. The 2026 SGLang SSD Expert Pack work (Q2_K/Q3_K
GGML expert-major packs, O_DIRECT, VRAM LFU/LRU cache; 6.92× decode over Ollama on
DeepSeek-V4-Flash) is the same problem this repository solved by hand in
deepseek_v4_1/device_experts.pyandcomponents/moe/placement.py— both now in RelicLLM. Independent arrival at the same design is confirmation, not redundancy.
6. Where PocketLLM is behind, ranked by what it costs¶
Sections 6.1 through 6.6 are about the multi-card stack and the C++ engine as of 8162937; those
runtimes have since moved to RelicLLM and the engine to the
relic-engine archive. The gap they record was real and
was one of the reasons for the split, but it is no longer a gap in this checkout.
6.1 The validated fast path is not the default path¶
pocketllm serve --backend cpp drove QwenEngine token by token from Python and was serial:
BackendBase holds a request lock, supports_batch is False unless enable_batching is set, and
enable_batching is reachable only as --backend-option, not as a CLI flag — --max-batch-size
alone does nothing because it is read only when batching is already on. The native --serve binary, by
contrast, owns a BatchScheduler with a waiting queue, chunked prefill and per-choice requests, and it
is the path the concurrency validation measured.
The same shape appears on DeepSeek: PersistentEngineAdapter's continuous_batching reads
POCKETLLM_CPP_BATCHED_DECODE, which defaults to 0, so the scheduler is clamped to width 1
regardless of --max-batch-size 8; paged_kv and chunked_prefill are also false on that engine.
So the repository contains a validated, faster serving path that its own CLI does not select. Three
concrete fixes, in order of value: expose enable_batching as a flag and default it on for the cpp
backend; make --max-batch-size imply batching; and make the DeepSeek default width match the
documented batch width, or make the clamp an explicit error instead of a silent one.
Landed. The first two are in: --enable-batching / --no-enable-batching exists, the cpp
backend defaults to the batch path at width 8, a --max-batch-size above 1 implies it, and a width
above 1 next to --no-enable-batching is a ConfigurationError rather than a resolution. The
default width is 8 and not 1 deliberately — a width of 1 is not a batch — which means the cost the
paragraph above describes now lands on the default path instead of on an opt-in; it is measured with
the prompt cache held fixed in
cpp_openai_concurrency_validation.md. The
third is still open, and it is narrower than it reads: the V4.1 adapter already raises
UnsupportedFeatureError for a width it cannot honour, so what remains is deciding the prefill-only
shape rather than removing a silent clamp.
6.2 No captured decode step in the C++ engine¶
This is the largest single measured headroom in the repository. The evidence is entirely local:
- Xing4.0, one card, eager decode step 148–178 ms against a replayed graph at 38–38.6 ms — 3.84× (the decode-graph record), with the bucket itself costing nothing (147.5 vs 148.1 ms) and capture exact to the bit against its own width.
- The launch-count probe found the reason: 22,155 ATen dispatches a step, 10,508 of which launch no kernel, against 11,536 launches and 46.5 ms of device work.
- In C++, the analogous blocker list is concrete and different: DeepSeek's decode names ~53 ops per
layer plus ~17 in MoE (~2,000–3,000 launches a step), and the hard blockers are the per-layer
blocking D2H (
memcpy_d2hon route indices atdeepseek_v4_engine.cpp:5042, then a host loop that names expert tensors by string, then 6 async H2D per staged expert), host-built attention index vectors H2D'd per row per layer (:4022-4073), 46device_synchronize()calls, 203 pageable H2D copies, anddevice_malloc/device_freeper call.
Both competitor designs name the fixes: vLLM solves position-dependence by padding batches onto
capture-size buckets (round_up(num_tokens, 1+num_spec_tokens)) and marks padding rows so MoE kernels
skip them, and solves non-capturable input preparation with StagedWriteTensor + UVA + Triton metadata
kernels; SGLang solves the phase problem by capturing decode and prefill separately and letting
breakable graphs handle prefill. Xing4's bucket ladder is the local instance of the first idea. Qwen
decode in the C++ engine is the plausible first target — 4 syncs, no per-layer D2H, and
reserve_paged_slot is a no-op except at block boundaries — while DeepSeek needs the per-layer D2H
removed before capture is even meaningful.
6.3 Scheduler features that matter at a stated goal, and those that do not¶
| Feature | PocketLLM | vLLM 0.30 | SGLang 0.5.20 | Verdict for this repository |
|---|---|---|---|---|
| Token budget | none; block budget charging worst_case_blocks = prompt + max_new_tokens |
max_num_scheduled_tokens, defaults 8192 offline / 2048 server |
PrefillAdder with rem_total_tokens, new-token ratio |
Worth it. Charging the full future footprint at admission is why head-of-line blocking hurts. |
| Preemption / priority | none by design ("head-of-line blocking is deliberate", batch_scheduler.cpp:298-304) |
recompute-only, victim by priority or FCFS-tail | retract-then-recompute, --retraction-policy length\|priority |
Low value here. A 22 GiB card that admits on the full ISL rarely needs to preempt; the single-request goal dominates. |
| Watermark / reserve | none | --watermark (fraction of blocks kept free) |
SWA/Mamba admission gates | Cheap. One knob, and it directly reduces the eviction churn the prefix cache causes. |
| Chunk size | fixed 4096 | per-step from the budget | derived from VRAM (20–35 GiB → 4096) | Already tuned; leave it. |
| Prefix sharing | Qwen only, block-aligned; 4 implementations | in the block pool, --prefix-match-unit |
one radix tree, default everywhere | Worth it, as unification rather than as new machinery. |
| Batched prefill | batch_prefill is serial per request (qwen_engine.cpp:5414) |
one batch mixes prefill and decode by construction | chunked prefill with mixed chunk opt-in | The largest unclaimed measured lever here — see §6.4. |
6.4 One measured throughput lever this repository has already priced¶
Independent of any comparison, the serving record leaves one large number on the table: merging the
admission wave into a single prefill forward, worth ~1.6× rather than the ~1.06× quoted in the engine's
own comment (serving throughput scaling, §"next
steps" item 2). The arithmetic is local and it is tight: a 325-token prompt costs 331 ms of prefill, of
which 131 ms — 40% — is paid before the first token-dependent FLOP, because a forward pass issues
129 collectives whatever the prompt's width. At L16 that is 5,385 ms of wave prefill against 3,414 ms
merged, and 49 against ~78 TFLOP/s on four cards. The merge also moves TTFT and prefill TFLOPS
together, which almost nothing else in this tree does.
It is unclaimed for a stated reason, not an unknown one: the 48 linear-attention layers carry
per-sequence state, so a merged forward needs a segmented recurrence, and the 16 full-attention
layers need a block-diagonal mask — without which a 5,162-row forward spends 16× on the dense
attention matrix what sixteen 322-row forwards spend in total. That is a kernel task with a measured
ceiling, which is the best kind of task this repository has. (The half that was a handover rather than a
forward is done — #353 gave each row its token when produced, worth −42% mean TTFT at L16, touching no
kernel.)
6.5 Collectives and overlap, where the Ascend lesson has not been ported¶
vLLM 0.30's --async-scheduling is auto-on, and MRV2's step loop has no CPU sync point and no
async barrier. SGLang's overlap scheduler is on by default. PocketLLM's only overlap is Qwen's prefill
slice overlap (projection_all_reduce_overlapped, slice ceiling 4, floor 1024 rows/slice), and its
comments record that decode takes the serial path at every setting; DeepSeek has no overlap at all.
The interesting thing is that this repository has already measured the same lever and shipped it —
on Ascend. The hand-written IPC all-reduce took rows=1 from 106.33 ms / 9.41 TPS to 77.32 ms /
12.94 TPS (+37.7%), and the device-side arrival wait took it further to 39.4–39.8 ms /
25.15–25.37 TPS, both now defaults. The mechanism was not the bytes; it was deleting the host round
trip around each collective (the first integration paid 12.9 ms of host ordering it did not need). The
CUDA path has no such optimization — every collective there is stock ncclAllReduce. A CUDA IPC
all-reduce is therefore a well-motivated hypothesis with a local precedent, and it is exactly the kind
of lever this repository's rules require to be measured in one process with interleaved arms before it
is believed.
6.6 Structured output and the OpenAI surface¶
PocketLLM's C++ engine (now archived in relic-engine) had
JSON mode and JSON-Schema constraints in core/json_constraint.cpp (743 lines) but no grammar or
regex (GBNF) constraints, and request-level token constraints were explicitly rejected on the TP>1
path. The surviving single-card runtime has neither JSON mode nor grammar constraints. Both competitors converged on xgrammar as the default
(one backend per engine, not per request) and both keep the FSM work integrated with speculative
verification — vLLM applies a grammar bitmask inside sample_tokens(), SGLang has spent 2026 on
"grammar × spec verify" specifically. If the roadmap's goal of dropping into Cursor/Continue/Open WebUI
is taken seriously, grammar-constrained decoding is the missing piece, and it is bounded work because
json_constraint.cpp is already an FSM-shaped constraint over the sampler.
Two smaller protocol gaps with named evidence, both of them about having had two servers: the native
and Python paths disagreed about semantics (cached_tokens behaviour and TTFT definitions differed
for the same checkpoint depending on which server you started), and two servers could be run against
the same checkpoint in one namespace with different supports_batch answers. With the C++ front end
archived and one runtime left here, neither disagreement is reachable in this checkout.
6.7 The version problem in the existing comparison¶
The current comparison is a good document against the wrong baseline. Its vLLM column is the local 2080 Ti fork: upstream base 0.21.0, fork version 0.1.15, squashed to a single commit. Upstream has since shipped 0.22 → 0.30 (nine releases, ~5,000 commits). Things in that document that no longer describe upstream:
- "PagedAttention" — deleted in 0.25 (
#47361); V0 is gone (docs/usage/v1_guide.md: "We have fully deprecated V0"). - The scheduler — V1's design is unchanged in shape (token budget, no phase machine, recompute-only
preemption) but now has
--watermark,--max-num-queued-reqs/tokens,scheduler_reserve_full_isl, DP prefill balancing, and anAsyncScheduler. - The worker — Model Runner V2 is the default since 0.29 and MRV1's removal is targeted for v0.32.
Every statement about
gpu_model_runner.pynow describes a deprecated path. - Prefix caching — on by default including Mamba since 0.28, deterministic
NONE_HASHsince 0.29,--prefix-match-unitfor the hash block size. - Batched-token defaults — 22 GiB-class cards now default to 8192 offline / 2048 with an API server, not 2048/128.
- GGUF and bitsandbytes — out of tree.
- All-reduce — FlashInfer's fused all-reduce is the default for TP CUDA groups but capability-gated
to sm90/100/103/107, so on sm_75 the fork's
CustomAllreduceremains the fast path. The fork is not simply "behind"; parts of it are the only working path for this hardware.
The honest framing for the head-to-head table is therefore "against the 2080 Ti fork of vLLM 0.21.0",
and it should stay that way until someone ports the comparison forward. Also stale on that page: it says
PocketLLM has no cross-request prefix caching, which stopped being true when V4.1 (#342), MiMo (#379)
and the C++ QwenEngine global cache landed — the first two moved to RelicLLM with their runtimes, and
the third is in the archived engine, while the surviving xing4 runtime keeps its own store.
7. Recommended sequence¶
The order is by (value × confidence) ÷ risk, and every item is scoped so it can be measured the way
docs/guides/benchmarking.md requires: one configuration per process, arms interleaved, a null arm
where the difference is small.
Tiers 1 and 2 are the implementation of the decision in §8 (scheduler unification, stages R1 and R2 of the refactor project); Tier 3 is the per-model and per-backend work that follows it. Read §8 first if the split between "one lifecycle" and "per-model implementations" is the question.
This sequence addresses the pre-cut repository. Items 1, 4, 5, 6, 8, 9 and 11 are about the C++
engine, the scheduler and the four non-xing4 runtimes, all of which have since left this tree — for
the relic-engine archive and
RelicLLM respectively. They are kept as the reasoning that was
made at the time; the surviving single-card runtime keeps the scheduler-free shape its guide describes.
Tier 1 — make what exists reachable¶
- Default
pocketllm serve --backend cppto the batch path. Exposeenable_batchingas a CLI flag, default it on, and let--max-batch-sizeimply it. Reuse the existing concurrency harness (scripts/bench_cpp_openai_concurrency.py) and record the ladder. Same forPOCKETLLM_CPP_BATCHED_DECODE=1on the DeepSeek adapter, or turn the clamp into a startup error. - Fix the false-passing test and add one served-path golden fixture per entry point: prompt,
environment, and the token ids that come out.
docs/models/README.md's status ladder currently rests on run records a human read; a fixture is what makes it checkable. Landed:tests/test_served_path_golden.pyrecords one real request through the one real entry point against recorded token ids. - Report capabilities from one place. One
RuntimeCapabilitiesper model runtime — attention kinds, cache kinds, prefix-cache support, graph-ability, TP rule, batch support — consumed byfactory.py, replacing the four_reject_unsupported_*bodies and three_IGNORED_OPTIONSsets.
Tier 2 — collapse the per-model duplication without collapsing the per-model code¶
- One adapter parameterised by a runtime spec.
mimoandxing4already share a byte-identical_decode,TokenStreamer,byte_sizeandsettled_text; the divergence that matters (rank collectives, single-process, prefix-cache shape) belongs in the model module, not the adapter. The 1,006/776/1,333-line adapters should become one adapter plus three specs. Keep the per-model runtimes exactly as they are — that is the part the evidence says is right. - One prefix-cache interface, three implementations behind it. The shared store already has the
right shape (block-chained hash over tokens, geometry mixed into the seed, whole-prefix anchors
rather than a radix tree, LRU under a byte budget). Give the C++
QwenEnginestore and the Xing4 latent store the same interface and metrics names, and deleteruntime/prefix_snapshot.pyor port its one consumer. Note that a radix tree is not the goal — vLLM itself uses a chained block hash, and SGLang's tree is a response to multi-tenant routing that a single-user box does not have. - The four CUDA-only translation units.
check_layeringalready coversengine/(see §3.2); what is open is thatdeepseek_v4_engine.cpp,dspark_engine.cpp,qwen_dspark.cppandqwen_dflash2.cppare excluded from the Ascend target outright, which is whybackend_unimplemented_ascend.cppis 317 lines of throwing stubs. This is the only item that makes the next model cheaper on both backends instead of just on CUDA, and it is Ascend-side work: the verification is the existing CUDA regression suite plus the Ascend parity targets, and neither can be run from a host with no/dev/davinci*.
Tier 3 — close the headline gaps¶
- Merge the admission wave into one prefill forward (§6.4). Segmented recurrence for the 48 linear-attention layers, block-diagonal mask for the 16 full-attention layers, expected ~1.6× on wave prefill and TTFT together at the serving ladder's prompt length. This is the only item on this list whose ceiling was already measured before the work started, and it is CUDA-and-Ascend neutral (the measurement is Ascend's; the constraint — per-sequence state — is Qwen's architecture, not the backend's).
- Capture the Qwen decode step in C++. Start from the Xing4 measurement: the goal is a replay near 38 ms rather than an eager step in the 150 ms class, and the prerequisites are the ones vLLM and SGLang both document — no host-dependent H2D inside the captured region, no position read from Python, padding onto a bucket ladder. Qwen decode is the first target because it has 4 syncs and no per-layer D2H. Before touching DeepSeek, remove its per-layer route D2H; nothing else will matter until that is gone.
- Port the Ascend collective work to CUDA — as a measured hypothesis. The Ascend numbers (+37.7% from the IPC all-reduce, then 39.4–39.8 ms with the device-side wait) make this the best-motivated unported optimization in the tree. On this box NVLink exists only between GPUs 2 and 3, so the claim to test is specifically a PCIe-topology decode one, TP4 across 0-3, arms interleaved in one process.
- Grammar-constrained decoding (GBNF/regex) on the existing constraint machinery, integrating with
speculative verification the way both competitors do it. Bounded because
json_constraint.cppis already an FSM over the sampler. - Admission tuning rather than a new scheduler: a token budget and a
--watermark-style reserve, keeping FCFS and head-of-line blocking. Preemption and priority are explicitly not recommended — no measured need at the stated goal, and they add the state that makesBatchSchedulersimple.
Non-goals, with reasons¶
- Do not build a generic graph IR or a hw-agnostic model definition. llama.cpp's IR costs a closed
quant-type enum and a
supports_opfalse-positive class of bug; vLLM is currently unwinding abstractions at the model boundary (#42770). PocketLLM's head models are all per-vendor by nature. - Do not port CUDA kernels to Ascend or vice versa. Already the rule; the 45-vs-148 op gap is a kernel backlog, not evidence for sharing code.
- Do not add preemption, KV swap, or PD disaggregation for a single-user box. Both competitors need them for multi-tenant routing; this engine's own head-to-head result is a single-request latency claim.
- Do not add multi-model-in-one-process. vLLM closed that request as not-planned (#21481), SGLang
answers it with a router, and the 2026 infrastructure answer is below the engine (kvcached's KV
virtualisation, SGLang's
ExpertPack). The old supervisor + host expert bank was that answer for this hardware; both moved to RelicLLM with the multi-card runtimes.
8. Decision: model implementations stay separate; the request lifecycle and scheduler become one¶
This section is the decision this page exists to record. It is stated as a boundary rather than a direction, because the failure mode on both sides is real: a generic data plane costs the per-model kernels that are this engine's measured advantage (§5), and five per-model serving stacks cost five copies of the same correctness risk (§4.2).
It is now RelicLLM's decision, not this repository's. The five serving stacks, the C++
BatchScheduler and the InferenceEngine contract discussed below have all left this tree — the
scheduler and the contract to the relic-engine archive,
the runtimes to RelicLLM. What is left here is one runtime, so
there is no lifecycle to unify: xing4 serves one request at a time through its own adapter and
declares supports_batch=False. The subsections below are retained as the design record of that
decision.
The decision has two halves, and they point the same way.
- Model computation stays per-model. Attention, KV layout, quantization format, expert placement, the forward's op sequence and its tuning knobs are written per checkpoint family and are not to be abstracted into a generic layer set. This is §5, and the evidence for it now includes vLLM's own position (#42770) and TensorRT-LLM's measured +64.5% from model-specific work.
- The request lifecycle and the scheduler become one. A prompt is admitted, chunked, KV-accounted, sampled per row, cancelled, streamed and reported to the client by one implementation, and that implementation drives every runtime — C++ or Python — through the same interface.
8.1 The interface already exists, and its design intent is already written down¶
cpp_engine/include/inference_engine.hpp:200 was the contract (the header is now in the
relic-engine archive), and it argued for itself:
This is deliberately the smallest set that
BatchScheduleractually calls, not a general model API: slot lifecycle, paged-KV accounting for admission, the two batched forward entry points, and a capability declaration. Everything model-specific — weight layout, attention kind, speculative decoding, prefix reuse, TP worker protocol — stays on the concrete engine, where it can keep its own types.Every method here is called once per scheduler iteration, never per token and never per layer, so dispatching them virtually cannot show up against a batched forward pass. Layer-level components are concrete types for the opposite reason.
That is the boundary this decision asks for, already implemented, with the cost question already
answered: five virtual calls per scheduler iteration is not a hot path. Capabilities
(inference_engine.hpp:9) is the other half and records a mistake the project has already made and
fixed:
What an engine can actually do, declared rather than inferred. The scheduler used to read
kv_total_blocks() == 0as "this engine uses a contiguous arena", which happens to be true for the one engine that existed but is an inference from an accounting field, not a statement of capability.
So the scheduler is already one implementation over a narrow contract with explicit capability declarations, and it already drives two engines with very different internals. What is missing is not the design. What is missing is that it drives only the native path.
8.2 What actually blocks it¶
(a) The Python runtimes do not implement the contract. They are separate processes with separate stacks, and each of the three has its own documented reason:
| Runtime | What the record says stands in front of the scheduler |
|---|---|
| v41 | "What it still has none of is batching, continuous batching and an MTP layer" (design record) |
| MiMo | "Batching, a scheduler — Not implemented — one request at a time" (design record) |
| Xing4 | "supports_batch is False — the trunk's forward flattens its input to one token axis, so two sequences given to it together would attend to each other" (design record) |
Xing4's is the structural one: its forward has a token axis but no independent value-batch axis. All three need per-row sampling and a prefill entry that accepts a budget and can resume.
(b) The Python control plane has no scheduler — it has a lock. pocketllm/backends/base.py:75:
The lock is intentionally at the backend boundary. Current native engines own one mutable KV-cache transaction, so concurrent calls must serialize until a request-aware cache scheduler is implemented.
The result is that the same checkpoint is served by two stacks with different semantics: the native
binary owns BatchScheduler, while pocketllm serve --backend cpp drives the engine token by token
from Python under a lock. They disagree about supports_batch, about prompt_tokens_details.cached_tokens,
and about TTFT — for the same model, on the same host, on the same port number.
8.3 The migration ladder already exists, and needs no big-bang¶
Capabilities makes the migration incremental: continuous_batching = false with max_slots = 1 is
"this engine can only run one at a time", and the scheduler already knows how to drive such an engine.
The precedent is PersistentEngineAdapter (engine/persistent_engine_adapter.cpp:55):
with the comment that Capabilities should not have, because both directions of error were already
observed:
a hardcoded false silently discarded
--max-batch-size, and a hardcoded true would advertise concurrency the engine does not deliver.
So step one is not "make v41/mimo/xing4 batchable". Step one is "register them under the one scheduler, declaring honestly that they are width 1". Each runtime's width then improves on its own schedule, and the scheduler does not change for it.
Step one, walked for all four runtimes. xing4, v41, mimo and torch each register under
the one scheduler behind --enable-batching. The scheduler takes an InferenceEngine*, so a Python runtime
needs to be one; pocketllm/backends/runtime_engine.py is that, plus the one thing the three
runtimes have in common that the scheduler cannot see: they generate by running to the end, and the
scheduler drives one token per call. RuntimeRun reconciles the two by running the runtime's own
loop on a thread and meeting it at the on_token/on_step callbacks the loop already had for
cancellation — so the block in 8.2 (a) is answered without touching a model implementation, and 8.2
(b) is answered for these runtimes because the lock it names is no longer what serializes their
requests.
What the three then share is the serving half — where the scheduler comes from, what the runtime
declares, how a row is submitted and how its result is read back — and that lives in one place,
SchedulerHost, rather than in three adapters that agree by inspection. What each keeps is the half
that is about its own checkpoint: the entry point, the tokens that end a turn, the card, and how a
finished generation is read.
The claim is deliberately weak and that is the point: one request at a time, continuous_batching =
False, the same tokens as the serialized path, and about the same wall time. What changed is whose
lifecycle it is. Raising the width is a change to the declaration, and the scheduler does not change
for it. The measured pair for xing4 is in
the checkpoint's own guide; the other two are asserted, not measured,
and their guides say so.
One thing the Python host taught the scheduler. BatchScheduler::schedule_loop used to ask the
engine for its device as its first act, on its own thread. For a native engine that is a virtual
call; for a Python one it crosses the language boundary and needs the GIL — and the thread that holds
the GIL is very often the one dropping the scheduler, because ~BatchScheduler runs stop(), and
stop() joins. A scheduler created and destroyed in the same instant deadlocked the interpreter
rather than shutting down, which is a failure a native-only host could not have produced. The
constructor asks now, on the thread that already calls caps and allocate_batch_slots, and hands
the loop thread the answer.
The torch plane is the last lifecycle, and it is the one with a second answer format.
--backend torch is what serves every checkpoint the four specific adapters do not claim, so it is
where a new model lands first and where a second implementation of admission, cancellation and
timings would have been copied to next. It registers like the others -- SchedulerHost, width 1 by
declaration -- but it cannot share the answer: its serial path returns the parsed assistant
message, with reasoning split out and tool-call markup removed, and no token ids at all. That is a
fact about that checkpoint's runtime rather than about serving, so its result step routes the
scheduler's tokens back through the legacy path's own formatter, and the two routes agree by
construction rather than by inspection. The boundary §5 defends, drawn one level down: the lifecycle
is shared, the answer is the model's.
It also had a check worth recording the absence of. An earlier revision compared the tokens it fed
the scheduler against the runtime's own done.completion_tokens and refused on disagreement --
unreachable, because submit_stream yields main_t1[~prompt_mask] per step while done slices the
same array trimmed at EOS, so the two are one number. A comparison that cannot fail is not a check;
the durable half of the claim, that every token the runtime streams is one the scheduler counted,
is asserted directly instead.
One program for every worker rank. The three run_worker scripts in §4.2 are gone. They were
generated as source text, one per runtime, and differed in three things: the import line, the
backend= string, and whether the checkpoint had loaded by the time the adapter was constructed.
That is a registry entry — pocketllm/backends/worker.py's WORKERS, keyed by the same name
select_backend resolves — and the program is one constant -c string that reads which runtime it
is from the environment, which is the only channel the supervisor has to a child anyway.
The gain is not the ~135 lines. It is that the child is now a real module instead of a string: a
worker that fails to start is the hardest thing in this tree to debug — the parent sees a child exit
and a rendezvous that never completes, and the exception that would explain it is inside a -c
script nobody can open. WorkerSpec also states the one difference that is easy to get wrong in
both directions: ready_at_construction is True only for the native adapter, whose engine loads in
its constructor, and False for the Python runtimes, which load inside run_worker. Announcing
early would tell the parent a rank is up while it is still inside a collective.
The same pass deleted the three copies of _publish_cache_metrics — the same nine Prometheus keys
in three adapters. A series a scrape sees only when one runtime happens to be serving is worse than
one it sees always, and nine names typed three times is how they stop agreeing.
8.4 Where the scheduler lives: the C++ library, driven from either host — recommended¶
Three reasons, in order:
- It is the only real scheduler in the tree. Waiting queue, block-budget admission, chunked prefill, one request per choice, and a measured 4.68× at 8 concurrent requests against vLLM's 3.82×.
- It is already exposed to Python.
cpp_engine/python/bindings.cpp:591binds it asQwenBatchSchedulerwithsubmit_request/poll_result, andpocketllm/backends/cpp_backend.py:800is already its client. The path is open; it is bound toQwenEngine*and needs to be bound toInferenceEngine*instead. Both files are now in the relic-engine archive. - Moving it to Python would rewrite the
Capabilitieslessons and add a layer to the C++ decode path — and that path is the entire reason the C++ engine exists.
The resulting shape: the scheduler is a library with two hosts (the Python control plane and the
native binary) rather than two schedulers. The Python runtimes become out-of-process workers on the
same control channel the C++ TP ranks already use (core/cmd_channel.cpp is a unix-domain socket and
carries no language-specific assumptions). Whether a model's forward is C++ or Python becomes invisible
to the scheduler.
One consequence has to be taken with it: the C++ HTTP front end (engine/openai_server.cpp, 2,117
lines, with its own tokenizer, prefix cache, cancellation, SSE and metrics) cannot stay a second
implementation. The fix is not to delete the native binary — its latency path is the product — but to
make the front end a library too, so the native binary is a host of the same implementation rather
than a copy of it. In the same commit the two capability declarations (pocket::Capabilities and
pocketllm.api.types.BackendCapabilities) become one, because they are the same fact in two languages.
The order of that deletion is not arbitrary, and it was checked rather than assumed. The C++ front end
is not only a second HTTP surface: three of its behaviours existed nowhere else in the tree — the
architecture-selected chat templater and its qwen3_5 tool-call parser (src/server/cpp_sidecar.py),
the reading of a generated text back into content / reasoning_content / tool_calls, and the
token-masking for response_format (core/json_constraint.cpp). A served-path capability is not
something to lose to a refactor, so the first two moved to
pocketllm/protocol/templating.py and the cpp backend applies them, before the server is deleted;
the third is a sampling-time kernel feature rather than a front-end one and survives the deletion,
but reaching it from the Python host needs the schema to cross BatchSamplingParams into the engine,
which is one new binding and its own issue. The measured consequence is in
the API guide: a cpp request through pocketllm serve
used to answer with a call as prose and return "stop" where the native binary returned
tool_calls.
8.5 The boundary¶
| One implementation | Written per model, deliberately |
|---|---|
BatchScheduler: admission, chunking, fairness, per-choice requests |
attention implementation (MLA / DSA / GDN / SWA / hyper-connection) |
InferenceEngine: slot lifecycle, KV accounting, the two batched forwards, capabilities |
KV layout and its kernels |
| the worker control protocol (five IPC mechanisms today) | quantization format and its kernels |
| HTTP / OpenAI / SSE / cancellation / metrics | expert placement decisions |
| the prefix-cache interface and its metric names | prefix-cache implementations (they are geometry-specific) |
| adapter: one, plus a per-runtime spec | the forward's op sequence and tuning knobs |
| test harness and served-path acceptance | the single-request latency path |
The per-model side of that table is exactly what §5 defends. The left column is what the audit in §4.2
found duplicated — five lifecycles, three schedulers, four HTTP servers, five IPCs, _decode byte-identical
between two adapters.
8.6 Where this reaches its limit¶
This is not a claim that 20 checkpoints become one runtime. vLLM's own layout is the honest
precedent: frontier models under vllm/models/<model>/{nvidia,amd,xpu,cpu}/ with per-vendor
implementations, the registry for the tail. The shape to plan for is N architecture families over
one lifecycle, with a small number of bespoke runtimes that genuinely do not fit. A family boundary
is real here and is already measurable: attention.py is 0.85-similar between qwen4_exp and
xing4_0, and the checkpoint roadmap itself is ordered by reuse — Bonsai reuses the Qwen3.8-27B runtime
field for field, and GLM-5.3-Flash is last because it joins two half-built paths.
8.7 Sequence¶
The order matters and the first item is not negotiable.
- Repair the acceptance surface first.
tests/test_cpp_backend_batching.pyis not a pytest test — it inserts a path, takessys.argv, and printsSKIPand returns, so under pytest it is a silent false pass. CI runs no tests, and the suite's baseline is 9 failures + 5 errors with a collection-time failure. Unifying the lifecycle while regressions are invisible means trading the only working acceptance mechanism for an abstraction with no evidence behind it. Fix the test, add one served-path golden fixture per entry point, and record the baseline as a set, because a falling count hides a new failure. Since landed: the C++ tests went with the engine, the served-path fixture istests/test_served_path_golden.py, and the baseline set intests/baseline_failures.txtis now empty. - Make the existing scheduler reachable (§6.1):
enable_batchingbecomes a CLI flag and defaults on for the cpp backend,--max-batch-sizeimplies it, and the DeepSeek clamp either defaults on or fails loudly. No runtime changes. - One capability declaration, consumed by
pocketllm/backends/factory.py, replacing four_reject_unsupported_*bodies and three_IGNORED_OPTIONSsets. - Bind the scheduler to
InferenceEngine*instead ofQwenEngine*; the Python server drives it. - Register the Python runtimes under it, width 1. v41, then mimo, then xing4 (cheapest first: it is single-card, no TP, experts resident).
- Collapse the adapters to one plus a per-runtime spec, and unify the prefix-cache interface behind the four implementations.
- Raise each runtime's width as its own per-model work: xing4's value-batch axis, then per-row sampling and resumable prefill for mimo and v41.
- Merge the two HTTP front ends. The layering check is no longer a companion item here:
check_layeringcoversengine/already, so steps 4–7 cannot move engine code past it.
Items 1–3 are scheduled as Stage R1, 4–6 as R2, and 7–8 as R3 in the refactor project.
9. What this page does not establish¶
- No number measured on vLLM 0.30.0 or SGLang 0.5.20 is reproduced here; this machine cannot run either on these cards (SGLang has no sm_75 AOT artifacts and retired its CUDA 12 lane; vLLM 0.30 does still build sm_75, which is worth knowing). The competitor numbers quoted are theirs, on their hardware, and are labelled as such.
- The claim that a CUDA IPC all-reduce would help here is a hypothesis transferred from Ascend, not a measurement on CUDA. It is stated with the arm design that would settle it.
- The claim that C++ graph capture is worth ~3.8× on Qwen is transferred from Xing4 in PyTorch, which is a different runtime with a different step composition. The local fact is that capture is worth 3.84× there, and that 22,155 dispatches a step is what it removes.
- Line-count comparisons between engines measure different things (vLLM counts a framework that hosts 368 architectures; PocketLLM, at this snapshot, counted seven hand-written runtimes). They are used here only to compare within an engine — cost per added model, duplication between siblings — never to rank engines.
- vLLM #42770, #44219 and #45470 are open RFCs, not policy. They are cited as evidence of direction, not as statements of what vLLM does today.
Evidence¶
Repository reads at 8162937. The paths below are of that snapshot: most of them have since left this
tree (see the note at the top of the page). Of what is named here, only xing4_backend.py,
pocketllm/api/, pocketllm/server/openai.py and src/models/xing4_0/ are still in this checkout;
cpp_engine/… is in the relic-engine archive, the
v41/blmimo/torch adapters and their src/models/deepseek_v4*, src/models/mimo_v2 modules are in
RelicLLM, and the native sources are in
relic-core.
cpp_engine/CMakeLists.txt(backend selection,POCKET_CORE_SOURCES,POCKET_CUDA_ONLY_ENGINE_SOURCES,check_layering),cpp_engine/engine/backend_unimplemented_ascend.cpp,cpp_engine/include/batch_scheduler.hpp,engine/batch_scheduler.cpp,engine/qwen_engine.cpp,engine/deepseek_v4_engine.cpp,include/{cuda_ops,qwen_cuda_ops,qwen_ascend_ops}.hpppocketllm/backends/{base,factory,cpp_backend,torch_backend,v41_backend,mimo_backend,xing4_backend}.py,pocketllm/api/{types,backend}.py,pocketllm/server/openai.py,pocketllm/supervisor.pysrc/models/prefix_cache.py,src/models/xing4_0/prefix_cache.py,src/runtime/prefix_snapshot.py,src/server/engine.py,src/runtime/pd_scheduler.py,src/models/xing4_0/graphs.py,src/models/xing4_0/decode_pos.py- Counts reproduced for this page:
grep -rn '<<<' cpp_engine→ 371 underbackends/, 0 underengine/andcore/;grep -rn cudaGraph cpp_engine→ 0; distinct*_cuda(declarations → 86 (cuda_ops.hpp) + 148 (qwen_cuda_ops.hpp), 45_ascendsymbols;difflibon the_decodebody ofmimo_backend.pyvsxing4_backend.py→ ratio 1.00.
Repository measurements cited: native C++ concurrency validation, Xing4 decode launch gap, Xing4 decode graph buckets, Ascend single-request decode, Ascend TP collective overlap, Ascend performance roadmap.
Upstream reads: vLLM v0.30.0 tag and release notes, RFCs #42770, #44219, #45470, #51212, #45133,
11162, #21481, #47187, #48277, #47361; SGLang v0.5.20 tag, release notes and the platform-plugin and¶
attention-backend guides; llama.cpp ggml-backend.h and HOWTO-add-model.md; ONNX Runtime execution
provider docs; TensorRT-LLM DeepSeek-V4 blog.
Related¶
- Refactor project: one request lifecycle, one scheduler — §8's decision as a tracked issue tree, tracked from #432
- PocketLLM vs vLLM vs SGLang — the earlier comparison, against the local 0.21.0 fork
- cpp_engine multi-backend refactor plan — the layering plan item 6 of §7 completes
- Backend unification design and PocketLLM refactor analysis (2026-09) — the two earlier drafts
- Feature roadmap for old hardware — the capability axis
- New model support on the 2080 Ti — the checkpoint axis
- Cross-request prefix caching on V4.1 and on MiMo-V2.6-Flash — the shared store item 5 of §7 generalises