Skip to content

Four launch-path fixes found benchmarking on a real node - #64

Merged
webdevtodayjason merged 3 commits into
mainfrom
fable/bench-depth-and-extra-env
Aug 26, 2026
Merged

Four launch-path fixes found benchmarking on a real node#64
webdevtodayjason merged 3 commits into
mainfrom
fable/bench-depth-and-extra-env

Conversation

@webdevtodayjason

Copy link
Copy Markdown
Contributor

I set out to measure decode against context depth and couldn't get the model loaded onto a second node. Four separate things were wrong. None of them show up unless you launch through the real path onto a box that isn't a clean room, which is a decent argument for the dogfood rule on its own.

Missing engine image

Engine images have been per-model since 0.5.4, so a node can be asked for an image it has never run. Nothing waited for that first pull. Docker started one implicitly, the launch confirmation timed out underneath it, and the caller got a bare {"error": "Failed to launch engine"} with no mention of an image anywhere. The entrypoint probe degraded at the same time, because docker inspect on a missing image returns nothing and we quietly fall back to a default argv prefix.

ensure_image() pulls it first now, with an hour of headroom since these images run about 20 GB, and refuses the launch if it can't get it.

Port collision

allocate_port() only skipped ports held by our own instances. It had no idea about anything else on the host. On a node where an unrelated service had owned 8000 for three weeks it handed out 8000 anyway, and vLLM died with OSError: [Errno 98] Address already in use after loading the whole model first. It socket-probes the host now, and the probe can be turned off for callers that don't want it.

1 MB request bodies

The API server never set client_max_size, so aiohttp's 1 MB default was in force. That caps a 262k-context model at roughly 190k tokens of prompt. The proxy 413s the request before the engine ever sees it, and nothing in the error tells you which hop refused. Hitting vLLM directly works fine, which is what makes it confusing.

That is how I found it. A 200k-token prompt is 1.01 MB. Default is 64 MB now, sized for a 1M-token context plus base64 image and video parts on the multimodal models, and configurable through max_request_mb.

extra_env

This one is a feature rather than a bug. Some engine settings have no CLI flag at all. The b12x FP4 kernel path on GB10 is selected purely by environment variables. Without a way to pass env per model, those models can only be reached by hand-rolling a container, which is exactly what the launch path exists to prevent.

extra_env carries them through NodeConfig, ModelInfo, catalog_recipe, and the load API, mirroring how extra_vllm_args already works. It merges over the computed NCCL env on purpose: our own default forces VLLM_NVFP4_GEMM_BACKEND=marlin on the pinned image, so a recipe asking for flashinfer-b12x has to win or b12x is unreachable.

Proof

The tests worth pointing at are the ones that pin the actual contracts. One binds a real squatter socket and asserts we allocate around it. One sets a recipe env value that collides with the computed one and proves the recipe wins. One proves start_solo won't run a container when the image can't be had. 15 new tests, 723 green.

One of these I caused myself. My first cut of the request-ceiling tests built a full create_app() outside a running loop, which bound the module-level download semaphore to the wrong loop and made an unrelated download test flaky. Pulled the arithmetic out into _client_max_bytes() and tested that directly instead. Three full-suite runs clean afterwards.

Not in here

No catalog recipe for b12x yet. extra_env is the plumbing; the recipe waits on measurements. Nothing changes for any model that already launches, and default behavior is unchanged apart from the request ceiling.

🤖 Generated with Claude Code

webdevtodayjason and others added 3 commits August 25, 2026 13:16
I set out to benchmark decode against context depth and couldn't get the
model loaded onto a second node. Four separate things were wrong, and none
of them show up unless you launch through the real path onto a box that
isn't a clean room.

Engine images are per-model since 0.5.4, so a node can be asked for an
image it has never run. Nothing waited for that first pull. Docker started
one implicitly, the launch confirmation timed out underneath it, and the
caller got a bare "Failed to launch engine" with no mention of an image.
The entrypoint probe degraded too, since docker inspect on a missing image
returns nothing and we quietly fall back to a default argv prefix.
ensure_image() now pulls it first and refuses the launch if it can't.

allocate_port() only skipped ports held by our own instances. It had no
idea about anything else on the host, so on a node where another service
had owned 8000 for weeks it handed out 8000 anyway and vLLM died with
EADDRINUSE after a full model load. It socket-probes the host now.

The API server never set client_max_size, so aiohttp's 1 MB default was in
force. That caps a 262k-context model at roughly 190k tokens of prompt: the
proxy 413s the request before the engine sees it, and nothing in the error
says which hop refused. That is how I found it, a 200k-token prompt is 1.01
MB. Default is 64 MB now, sized for a 1M-token context plus base64 video
parts on the multimodal models.

Last one is a feature, not a bug. Some engine settings have no CLI flag at
all: the b12x FP4 kernel path is selected purely by environment. Without a
way to pass env per model those models can only be reached by hand-rolling
a container, which is the thing the launch path exists to prevent.
extra_env carries them, and it merges over the computed NCCL env on
purpose. Our own default forces VLLM_NVFP4_GEMM_BACKEND=marlin on the
pinned image, so a recipe that wants flashinfer-b12x has to win.

The tests worth pointing at: one binds a real squatter socket and asserts
we skip that port, one proves a recipe env value beats the computed one on
the same variable, and one proves start_solo refuses to run a container
when the image can't be had. 15 new tests, 723 green.

One I caused and fixed. My first cut of the request-ceiling tests built a
full create_app() outside a running loop, which bound the module-level
download semaphore to the wrong loop and made an unrelated download test
flaky. Pulled the arithmetic out into _client_max_bytes() and test that
directly. Three full-suite runs clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…weep

The benchmark lived in ops/, which is gitignored, so it only ever existed
on my laptop. It's a user-facing tool and anyone running AINode wants it
for the same reason I did, so it belongs in scripts/ where it ships.

The new part is depth mode. Peak tok/s is the number everybody quotes and
it doesn't describe what you get in real work, because decode reads the
occupied KV cache every token and a 4k prompt and a 128k prompt are
different machines. --mode depth sweeps prompt sizes and reports decode at
each one.

Three things keep the numbers honest. Every request carries a unique nonce
at the FRONT of the prompt, so prefix caching can't serve the prefill and
make depth look free. The x-axis is the server's own usage.prompt_tokens
rather than my estimate, so a bad chars-per-token guess shifts nothing.
And decode is timed from the first token, so prefill is excluded instead
of averaged in, which is the difference between a decode rate and an
agent-loop rate.

Two bugs in the existing code came out of building it. It counted SSE
chunks and called them tokens, but under speculative decoding one chunk
carries several accepted tokens, so it reported roughly rate divided by
acceptance length. That read 9.2 tok/s on a model I know does 19, and it
looked plausible enough to publish. It uses usage.completion_tokens now.
It also only looked at delta.content, and this vLLM build emits reasoning
as delta.reasoning, so a reply that was all thinking counted zero tokens
and never set TTFT at all.

Measured on one GB10 with Qwen3.8-27B NVFP4 after the fix: decode goes
20.1 to 16.2 tok/s from 4k to 126k, so 32x the context costs 20% of the
rate. TTFT over the same range goes 1.9s to 112s. Prefill is the thing
that hurts at depth, not decode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The file lived in gitignored ops/ so it had never been linted. Split the
combined import line and dropped a semicolon.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@webdevtodayjason
webdevtodayjason merged commit c020dc6 into main Aug 26, 2026
1 check passed
@webdevtodayjason
webdevtodayjason deleted the fable/bench-depth-and-extra-env branch August 26, 2026 02:05
@webdevtodayjason webdevtodayjason mentioned this pull request Aug 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant