fix(server): fail-fast unschedulable KV waits - #227
Conversation
TESTERS WANTED — BF16 / FP16 MoE long-prompt / KV path on ROCmThis PR is quant-agnostic (scheduler fail-fast when KV cannot admit a wait + We cannot reliably run BF16 MoE 26B here (~52 GiB weights, ~30 GiB host RAM). Dense 12B BF16 is not a valid MoE stand-in for expert/KV pressure at SOUL-scale prompts. Please test if you can load BF16/FP16 MoE on ROCm
ReportGPU + ROCm, model/dtype, Pairs with #234 (sampler) and #228 (FP8 MoE kernels; separate lane). |
localai-org-maint-bot
left a comment
There was a problem hiding this comment.
The fail-fast behavior is important, but the scheduler and serving changes need regression coverage before merge. Please add tests proving an impossible full-sequence admission terminates with the intended error (rather than waiting forever), an adequately sized request still admits, and max_tokens=-1/oversized values follow the documented clamp. Please also bring the branch onto current main and clear the repository gates.
Addressing CHANGES_REQUESTED (localai-org-maint-bot)Regression coverage added
Also fixed CompletionRequest path to normalize Lab run (HIP build): Docs / gatesUSAGE already documents the fail-fast + clamp on this branch. Will rebase onto current |
Address CHANGES_REQUESTED on mudler#227: - Scheduler.abort_unschedulable_waiting: abort when prompt cannot fit KV (tiny num_blocks); leave admittable waiters; no-op while running - CompletionRequest max_tokens=-1/0 normalize (parity with chat path) - Protocol unit tests for Hermes-style max_tokens=-1 FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: Hermes:grok-4.5 [Hermes]
Address CHANGES_REQUESTED on mudler#227: - Scheduler.abort_unschedulable_waiting: abort when prompt cannot fit KV (tiny num_blocks); leave admittable waiters; no-op while running - CompletionRequest max_tokens=-1/0 normalize (parity with chat path) - Protocol unit tests for Hermes-style max_tokens=-1 FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: Hermes:grok-4.5 [Hermes]
7857045 to
1d8061b
Compare
Address CHANGES_REQUESTED on mudler#227: - Scheduler.abort_unschedulable_waiting: abort when prompt cannot fit KV (tiny num_blocks); leave admittable waiters; no-op while running - CompletionRequest max_tokens=-1/0 normalize (parity with chat path) - Protocol unit tests for Hermes-style max_tokens=-1 FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: Hermes:grok-4.5 [Hermes]
1d8061b to
8cc62b9
Compare
|
Rebased onto current |
|
Fixed agent-record: documented |
|
Reviewed on CUDA. The deadlock fix is good and I want it — two findings before merge, one of which you also have in #234. Verified (GB10 sm_121a, rebased on current
|
…stant Three open PRs each grew their own workaround for the same defect, with three different answers: #227 clamps to 16, #234 and #228 to 4096. All three would conflict with each other, and all three truncate. This fixes it once, in the one place that owns the translation. Hermes and some OpenAI clients send `max_tokens: -1` for "no client-side limit". `SamplingParams::PostInit` rejects anything below 1, which is what made a clamp look necessary — but a constant is the wrong answer, because "unlimited" already has a correct representation here: UNSET. The engine then generates to `max_model_len - seq_len` (`v1/engine/input_processor.cpp:175-180`, a 1:1 mirror of vllm `input_processor.py:317-321`), which is exactly what the client asked for. With a constant, a client asking for unlimited against a 32k model gets 4096 (or 16) tokens back with `finish_reason=length`, indistinguishable from a real limit it set itself. It also hardcodes a policy number into the protocol layer, where `VT_SERVER_MAX_NEW_TOKENS` is already the tunable. Both request paths are covered: `/v1/completions` (`max_tokens`) and `/v1/chat/completions` (`max_completion_tokens` preferred over `max_tokens`, matching chat_completion/serving.py:299). A positive value is honoured unchanged, and a serving-resolved default still wins over an unset request value. RED before, GREEN after, same test and command, on dgx with the new test built against UNPATCHED main: before: test case THREW exception: max_tokens must be at least 1, got -1 29 cases | 28 passed | 1 failed; 175 assertions | 2 failed after: 29 cases | 29 passed | 0 failed; 179 assertions | 0 failed `test_openai_protocol` gains six subcases pinning the semantics: -1 and 0 unset on both paths, a positive value unchanged, and -1 yielding to a serving default when one is supplied. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: Claude:claude-opus-5 [ClaudeCode]
…ans UNSET Three open PRs each grew a workaround for the same defect with three different answers: #227 clamps to 16, #234 and #228 to 4096. They would conflict with each other and all three truncate. Fixed once, in the layer that owns the translation, so those PRs can drop it and rebase. Hermes and some OpenAI clients send max_tokens=-1 for "no client-side limit"; PostInit rejects <1, which is what made a clamp look necessary. But "unlimited" already has a correct representation: UNSET, which routes to max_model_len - seq_len (input_processor.cpp:175-180, mirroring vllm input_processor.py:317-321). A constant silently truncates exactly the long-context request that asked to be left unlimited. RED before / GREEN after on dgx, the new test built against UNPATCHED main: before: THREW 'max_tokens must be at least 1, got -1'; 28/29 cases, 2 failed after: 29/29 cases, 179/179 assertions Covers both request paths and documents the semantics in docs/USAGE.md. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: Claude:claude-opus-5 [ClaudeCode]
|
Heads-up: the A non-positive RED before / GREEN after on dgx, the new test built against unpatched main:
Everything else in your PR stands — this is only about the shared hunk. |
Rebase + review fixes (addressing CHANGES_REQUESTED)Rebuilt tip-of-main as Dropped
Finding 1 fixed
Kept
Local gates: device-leakage, public-doc-tables, doc-checkpoint, env-doc, pr-size — OK. |
0c43f73 to
3a94808
Compare
|
Rebased onto latest |
Tip-of-main rebuild (clean fail-fast only)Rebuilt as single commit on current Included
Explicitly NOT included (would clobber main)
Local gates green. Please re-run CI. |
3a94808 to
ac5725f
Compare
ac5725f to
6da18fe
Compare
02254bc to
fbc0167
Compare
Abort waiting requests that cannot fit in the configured KV pool while the engine is idle, instead of spinning forever at model_executed=0. Rebased onto current main; docs only add the surface (no release-archive version reverts). FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: Hermes:grok-4.5 [Hermes]
fbc0167 to
32b771d
Compare
- serving_chat: sse_ping reason=queue_wait|engine_wait|decode_stall|empty_output (drop misleading prefill_wait=1) - input_processor: reject prompt_tokens > serve max_model_len; clamp max_tokens to remaining window; wire set_max_model_len from model_loader - cherry-pick mudler#227 abort_unschedulable_waiting so idle WAITING cannot spin forever Lab park on :8010; product path for Hermes wedge class. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: Hermes:grok-4.5 [Hermes]
|
@localai-org-maint-bot Status check against CHANGES_REQUESTED / localai-bot notes:
Sanitize ASan/TSan reds match the ambient main baseline on every open PR — not unique to this tip. Happy to rebase again if main moves. |
…and at admission (#227) External PR #227 reported a real defect: a prompt larger than the KV pool (`--num-blocks x --block-size`, 256x32=8192 by default) is admitted, never allocates, and the engine spins forever at `model_executed=0` with an idle GPU. That PR's remedy was to abort the head waiter from the scheduler. vLLM does not do that -- `scheduler.py:919-940` peeks and `break`s, and never aborts an unschedulable waiter. Probing the live allocator to decide also POISONS the prefix cache: the probe reaches `block_pool.cache_full_blocks(...)`, publishes block hashes for KV no forward pass ever wrote, and `free_blocks` deliberately keeps those hashes matchable -- measured, one probe turned the next 64-token prefill into a 16-token one that "matched" 48 tokens of uninitialized KV. So this lands the two guards vLLM actually has, neither of which existed here. STARTUP (`kv_cache_utils.py:751-788` `_check_enough_kv_cache_memory`, with `:791-798` `max_memory_usage_bytes`, `:800-851` `estimate_max_model_len` and `:1967-2027` `_auto_fit_max_model_len`, all @ 555967922; applied at `LoadedEngine::ResolveMaxModelLen`). A pinned `--max-model-len` the pool cannot hold is REFUSED with upstream's message, its numbers, and the flags that close the gap. An UNPINNED one is auto-fitted down to the pool and logged, which is what keeps the default path alive -- our `ResolveNumBlocks` still has no memory profiler (TODO ROAD-V1-MEM M3), so the fallback 256 blocks is a placeholder, not a measurement, and refusing against it would refuse every large-context model. Deviations recorded in the header: upstream's two `Callable`s are passed as values (both are pure arithmetic here), and `estimate_max_model_len`'s binary search is written closed-form because `KVBytesPerBlock` does not vary with the block count. `if kv_cache_spec:` (`:872-878`) is mirrored as `bytes_per_block > 0`, so an attention-free or pure-GDN model is never refused. ADMISSION (`input_processor.py:387-432` `_validate_prompt_len`, decoder arm). An empty prompt, and one at or past the RESOLVED `max_model_len`, raise the new `InputValidationError`. `api_server.cpp` catches it ahead of its generic `std::exception` arm and answers **HTTP 400 `BadRequestError`**, mirroring `serve/utils/error_response.py:62-65` -- a client mistake, not a 500 and not a finish reason. `InputProcessor` now takes the resolved serving length rather than the raw `max_position_embeddings`, which is what upstream's `model_config.max_model_len` is; without that the two guards would disagree. Together they make the post-condition the scheduler relies on true: anything admitted fits in KV, so the wedge PR #227 hit cannot form. Also kept from PR #227, repaired: the two-phase prefill-progress logging (`begin` at schedule, `running`/`done` after `execute_model`, so `elapsed_s` is real wall time instead of the cost of scheduling), and the `VT_ENGINE_STEP_LOG` engine heartbeat that tells an idle engine from a stalled one. The progress state map leaked one entry per request for the process lifetime; it is bounded again, now also evicting requests the scheduler has forgotten, which the old `logged_done` rule alone never did for an aborted prefill. NOT taken from PR #227: `abort_unschedulable_waiting` and its three tests (the prefix-cache poisoning above is inherent to probing the live allocator, and the tests passed with the guard deleted); the `reset_prefix_cache()` call from the scheduler (upstream only does that from the explicit RPC); and the prose paragraph inserted INTO the middle of the `docs/USAGE.md` flag table, which split it so every row below lost its header. That PR also never compiled anywhere -- it rewrote the `EngineCoreProc` ctor to 5 parameters against a 6-parameter declaration -- and reverted `post_step`, the `check_for_draft_tokens` threading and the `VT_SPEC_TRACE` instrumentation, the omission that made MTP/DFlash/ ngram/DSpark inert on the CLI and server. This branch is based on `origin/main`, so none of that is carried. `scripts/dgx-online-serving.sh` pinned `--max-model-len 262144` against a 4736x32 = 151552-token pool, four times what it could serve; the flag is dropped so the length auto-fits to the pool, which is what that server actually served. Issue: #83 (M4, refuse before allocating). Closes the report in #227. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: Claude:claude-opus-5-1m [ClaudeCode]
Re-merge before re-gating, per the landing rules. Conflict-free. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: Claude:claude-opus-5-1m [ClaudeCode]
#227) Closes the gap @bakon11 found in #227, but NOT with the mechanism that PR proposed. The symptom was real -- a request whose KV need can never be satisfied hangs the server -- and the diagnosis was correct. The fix was at the wrong layer, and three findings made it unlandable as filed: 1. It had never compiled anywhere: core_proc.h:185 declares a 6-param ctor, the PR rewrote core_proc.cpp:20 to 5 and never touched the header. Reproduced on the PR branch in isolation, so merging could not fix it. 2. It silently REVERTED landed work -- a paste from a pre-SPEC-DSPARK checkout that deleted post_step(model_executed), the check_for_draft_tokens threading, and the VT_SPEC_TRACE instrumentation. That exact omission previously made MTP/DFlash/ngram/DSpark ALL inert on the CLI and server path, with green tests. 3. Its abort_unschedulable_waiting probe POISONED THE PREFIX CACHE, measured rather than argued: the probe calls the real allocate_slots, which publishes block hashes into cached_block_hash_to_block, and free_blocks deliberately KEEPS those hashes matchable -- but no forward pass ever ran. Control prefills 64 prompt tokens; after ONE probe the next schedule() prefills 16, matching 48 tokens of uninitialized KV. Restoring the free count is therefore not a sufficient repair; the probe must not touch the live allocator at all. Its own three tests were vacuous: deleting the !running.empty() guard they exist to protect left all three green, because the 500-token waiter fits in the 1000-block fixture and the probe succeeds regardless. vLLM does not abort unschedulable waiters. scheduler.py:919-940 peeks and breaks. It prevents the state at two OTHER layers, and NEITHER existed here: - startup, kv_cache_utils.py:751-788 _check_enough_kv_cache_memory (plus :791-798, :800-851, :1967-2027 auto-fit, :2160-2174 call site), now four free functions in v1/core/kv_cache_utils.{h,cpp} applied at LoadedEngine::ResolveMaxModelLen; - admission, input_processor.py:387-432 _validate_prompt_len, called from all three process_inputs* entry points and surfaced as HTTP 400 via the BadRequestError mapping at serve/utils/error_response.py:62-65 -- not as a finish reason on a 200 with an empty body, which is what #227 emitted. RED-then-GREEN by mutation on the final tree (guards reverted, tests untouched, files restored byte-identical after): RED showed prompt_tokens=40 admitted against max_model_len=32 -- the defect itself -- plus the 400 assertion reading 200. GREEN: test_kv_cache_utils 35/35, test_input_processor 16/16, test_loaded_engine_dense 15/15, test_openai_api_server 51/51. Full CPU ctest 369/369 at load 28. One judgement call recorded rather than buried: a bare mirror of the startup throw would refuse to start most large-context models, because ResolveNumBlocks still returns a hardcoded 256 (the gpu_memory_utilization profile is unported). Upstream's own auto-fit was therefore ported too, so the throw fires only when the user PINNED a --max-model-len the pool cannot hold. That required dropping --max-model-len 262144 from scripts/dgx-online-serving.sh, which asked for 4x its own 151552-token pool; that edit is bash -n clean but UNEXECUTED -- no GPU here. Also not carried from #227: the scheduler-side reset_prefix_cache() on stall (upstream only does this from the explicit RPC), the unbounded PrefillStates map that dropped its size>64 eviction, and the prose paragraph inserted into the middle of the docs/USAGE.md flag table, which split every row below it. Owed and stated plainly: no committed spec (this is a PR repair with no roadmap row; both guards are recorded in porting-inventory and linked to #83 and #227), no fresh-reviewer pass, and no GPU verification -- the startup guard changes resolved max_model_len on the default path for large-context models, which is a real behavior change on gates that need dgx. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: Claude:claude-opus-5 [ClaudeCode]
|
The defect you reported is fixed on main as You were right about the bug: a prompt larger than the KV pool is admitted, never allocates, and the engine spins at What was not taken, and why. That is measured, not theoretical — two of us found it independently. One probe of a 32-token waiter made an identical later request match 16 tokens of never-computed KV; the other measurement turned a 64-token prefill into a 16-token one "matching" 48 tokens of uninitialized KV. Same mechanism. Two smaller things in the same function: What landed instead are the two guards vLLM actually has, neither of which existed here — Kept from this PR, repaired: the two-phase prefill-progress logging ( Closing as fixed-otherwise. The report was good and the diagnosis was right; only the remedy changed. |
Summary
Hermes-class clients can send huge system prompts. With default KV (
256×32=8192tokens) oversized prompts never admit underfull_sequence_must_fitand used to sit inwaitingforever (model_executed=0, GPUs idle).max_tokens— main already treats non-positivemax_tokensas unset (SERVE-MAXTOKENS-UNSET)ENVIRONMENT.md(VT_ENGINE_STEP_LOG),USAGE.mdscheduler fail-fast sectionLand order
Test plan
TESTERS WANTED (BF16/FP16 MoE)
Need multi-GPU ROCm hosts with host RAM ≫30G on MoE (Gemma-4-26B class). Dense 12B is not a MoE proxy.