Skip to content

fix(scheduler): clear SpecPrefill state after failures and cache rejects - #4035

Merged
jundot merged 2 commits into
jundot:mainfrom
tc3oliver:fix/specprefill-stale-active-id
Oct 2, 2026
Merged

jundot merged 2 commits into
jundot:mainfrom
tc3oliver:fix/specprefill-stale-active-id

Conversation

@tc3oliver

@tc3oliver tc3oliver commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Problem

If a SpecPrefill request fails after its sparse prefill, stale SpecPrefill state can stop the engine from admitting requests, while stale sparse indices can make fallback paths prefill the wrong tokens.

  • A decode error that reaches fail_all_requests (from the engine loop's exception handler) leaves _specprefill_active_request_id and the offset RoPE patch in place with no request behind them. _schedule_waiting defers everything from then on.
  • A cache-corruption error goes through _recover_from_cache_error and puts the request back in waiting, but the id stays set, so the retried request is deferred by its own leftover id and never finishes.
  • _reset_request_for_reprefill re-prefills from the full prompt with cached_tokens = 0, but scoring doesn't run again. After a prefix-cache hit the request keeps indices that point into the old suffix, so the retry prefills the wrong tokens at the wrong positions without any error.
  • _validate_cache has the same problem. Scoring runs during the prefix-cache lookup, before validation, so when validation rejects the restored cache the fallback switches to the full prompt but keeps the suffix-relative indices. The regression test covers the recurrent-slot-count rejection path using a hybrid model and a persistent cache block whose recurrent state doesn't match the current model.

Related: #2336. Its Bug B is the _recover_from_cache_error case above, reported there as a hang with TurboQuant KV and SpecPrefill on a real model. Bug A (the sparse-prefilled cache not being TurboQuant-converted) is not touched here.

Fix

  • fail_all_requests and _recover_from_cache_error call _cleanup_specprefill, as abort, normal completion and generation-overflow recovery already do.
  • _reset_request_for_reprefill drops the indices, so the retry prefills densely, like the existing fallback when sparse prefill fails. This also applies to the generation-overflow retry, which uses the same reset.
  • The _validate_cache rejection fallback drops the indices scored against the rejected cache (second commit).

Regression coverage

Tests reach each state through EngineCore.generate with tiny random-init models and the repo's mock tokenizer. The only faults are a model forward that raises once during decode and, for the _validate_cache case, a stored block rewritten on the SSD cache between two engine sessions.

On main the three test cases in test_specprefill_lifecycle.py time out with the engine stuck, and in the _validate_cache test the model is given a sparse prefill of suffix tokens where the full prompt should start. Removing either index reset on the branch makes its test fail: the corruption retry, or the _validate_cache fallback, feeds the model suffix tokens instead of the full prompt.

Validation

  • tests/test_specprefill_lifecycle.py and tests/test_specprefill_cache_validation.py: 4 passed on the branch, 4 failed on main
  • tests/test_specprefill*.py: 111 passed
  • tests/test_scheduler.py tests/test_engine_core.py tests/test_prefill_oom_graceful.py tests/test_exceptions.py: 623 passed
  • python -m pytest: 14984 passed, 381 skipped, 86 deselected, 0 failed
  • ruff check and black --check on the two new test files. omlx/scheduler.py already doesn't pass Black on main; the lines added here do.

Not tested

I haven't run a representative real-model hardware check for this change. The tests above use tiny random-init models, including the hybrid cache-validation case, so they exercise the scheduler and persistent-cache paths but not a production-size model or the TurboQuant workload from #2336.

…corrupts

The engine stopped admitting requests after a SpecPrefill request failed
mid-decode or hit cache corruption. Neither fail_all_requests nor
_recover_from_cache_error released the active SpecPrefill id or the offset
RoPE patch, so _schedule_waiting kept deferring every request, including the
corruption retry of the SpecPrefill request itself.

Both paths now call _cleanup_specprefill, as abort and generation-overflow
recovery already do.

A retried SpecPrefill request (cache corruption and generation overflow) now
prefills densely. Its indices and position offset were scored against the
pre-reset cache state: after a prefix-cache hit they index the uncached
suffix. The reset sets cached_tokens to 0 and does not re-run scoring, so the
retry fed those suffix-relative indices into a sparse prefill of the full
prompt and forwarded the wrong tokens at the wrong positions, with no prefix
KV. _reset_request_for_reprefill now drops the indices, the same fallback
used when sparse prefill fails.
… fails validation

SpecPrefill scores a request while its prefix cache is being prepared, so
the selected indices are relative to the uncached suffix and the RoPE offset
is the restored cached_tokens. When _validate_cache then rejected the
restored cache, the fallback reset the request to prefill the full prompt
from position 0 but kept specprefill_indices. The sparse path applied the
suffix-relative indices to the full prompt, so the wrong tokens were
prefilled at the wrong positions, with no prefix KV and no error. Scoring
does not run again for that request, so nothing corrected it.

The fallback now clears specprefill_indices, and the request is prefilled
densely. specprefill_position_offset is only read when indices are set, so
it is left alone.

The regression test stores a prefix on a tiny hybrid model through the
paged SSD cache, rewrites the stored block so its recurrent layers carry a
different slot count than the live model, and checks in a second engine
session that the rejected restore is followed by a dense prefill of the
full prompt.
@tc3oliver
tc3oliver force-pushed the fix/specprefill-stale-active-id branch from 99936fe to ef0ecfd Compare September 27, 2026 18:45

@jundot jundot left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each of the four paths is reachable on current main, and reverting any one hunk breaks only its matching test. I'll move the coverage into the existing scheduler tests in a follow-up. LGTM, merging.

@jundot
jundot merged commit 37d9de7 into jundot:main Oct 2, 2026
3 checks passed
tc3oliver added a commit to tc3oliver/tc3oliver that referenced this pull request Oct 2, 2026
The profile read like generated copy: a keyword ribbon under the title,
em-dash separators on nearly every line, number-dense project blurbs
and a technology list at the end. The intro is now a short first-person
paragraph, each project is one plain paragraph, and the curated PR
summaries in data/oss-contributions.toml say what broke and what the
fix changed without the extra framing.

PiShip, the distribution and governance layer for the Pi coding agent,
was missing from the projects. It is added with its pre-release status
stated as the project's own status page states it.

The generator's line format used em dashes as separators; it now uses
a colon, parentheses and a comma, so the weekly sync does not put them
back. The regenerated record also picks up jundot/omlx#4035, merged on
October 2.

Local .omc/ state is ignored.
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.

2 participants