Skip to content

An inference scheduler deadlocks under sustained load and stops issuing work

A continuous-batching scheduler admits requests against a token budget it must not exceed. Under sustained concurrency the accounting can reach a state where no admitted request can advance and no new one can be admitted, so the server stops issuing work while remaining connected and apparently healthy.

Quick answer

The scheduler is wedged, not overloaded. Lower the maximum batched token budget and the concurrent request limit so it runs with headroom, and alert on zero completions with a non-empty queue.

Performance#tensorrt-llm#scheduler#deadlock#in-flight-batching#token-budget#concurrency

What this failure is

A scheduling deadlock in a continuous-batching inference server where admitted requests collectively claim more of the token budget than it holds, leaving none able to progress and none able to release, so the server stops issuing work without failing.

Why it happens (the mechanism)

Continuous batching trades simplicity for utilisation: requests join and leave a running batch, and a shared budget decides who is admitted. Any admitted request whose demand can grow turns that budget from a bound into a claim, and claims that cannot be satisfied or withdrawn are the definition of deadlock. High concurrency is what makes the interleaving that reaches it likely.

What you'll observe

  • Throughput reaches zero while the process stays up and keeps accepting connections
  • It only appears above a certain request rate, so it never reproduces in a quiet environment
  • An assertion about token accounting fires, which reads as an internal bug rather than saturation
  • Restarting clears it until the load returns

Common symptoms and what they mean

SymptomWhy it happens
An assertion comparing a running token total against the configured maximum, firing under loadAdmission is a resource-allocation decision made repeatedly against a shared budget. If a request's demand can grow after it has been admitted, the scheduler can reach a state where the total exceeds what it promised itself, with every admitted request needing more budget to finish and none able to release any.
The scheduler making no forward progress while requests remain queuedThat is a deadlock in the classical sense rather than an overload: no participant can proceed and none will yield. It is why the server neither completes work nor sheds it, and why nothing times out on its own.
Throughput collapsing to zero without any request completing or failingIt is load-dependent because the state requires a particular interleaving of admissions and completions. At low concurrency the budget is never contended tightly enough to reach it, which is why it survives testing and appears in production.
The condition appearing only above a threshold request rate and clearing on restartAdmission is a resource-allocation decision made repeatedly against a shared budget. If a request's demand can grow after it has been admitted, the scheduler can reach a state where the total exceeds what it promised itself, with every admitted request needing more budget to finish and none able to release any.

Which systems are affected

  • Continuous and in-flight batching schedulers that admit against a global token budget
  • Deployments running near their configured maximum batched tokens rather than comfortably below it
  • Speculative decoding and other features that make a request's token demand vary during its lifetime
  • Long-context traffic, where a single request can consume a large share of the budget

How to confirm this is the problem

Apply the following checklist to a small reproduction: each box below is a positive signal that you are looking at this exact failure rather than a sibling in the same taxonomy.

  • Check whether completions have stopped while the queue is non-empty; a wedged scheduler shows both at once, whereas an overloaded one still completes work slowly.
  • Correlate onset with request rate rather than with any deployment change. A threshold that appears above a particular concurrency and not below it indicates a state reachable only under contention.
  • Reduce the batched token budget and replay the same load. Surviving with a smaller ceiling confirms budget contention rather than a fault in any single request.

Example training logs (fingerprint)

training.log (synthetic fingerprint)
AssertionError: assert total_num_tokens <= self.max_num_tokens
[TRT-LLM] [E] Encountered an error in forward function
[TRT-LLM] [E] Error in event loop

Timestamps and exact values vary across runs, but the pattern. An info-level start, an early WARN, an ERROR carrying the symptom. Is the actual fingerprint you should alert on. The Denpex platform flags this combination automatically.

Root cause, fix & prevention, signed in

Sign up free to see why this failure really happens, the exact remediation steps, and the production-grade prevention pattern. You also get 3 free full diagnoses for your own training logs.

Sign up free. Unlock the full analysis

No credit card · 3 free diagnoses · Instant access

Why the recommended fix works

Operating below the ceiling means an admitted request's growth can be absorbed rather than contended, so the state is never reached. Reducing concurrency does the same from the other side by limiting how many claims exist at once. Neither improves peak throughput on paper, and both are what keep the measured throughput above zero.

Code examples

typical reference pattern
// Typical pattern:
import torch.optim as optim
optimizer = optim.AdamW(model.parameters(), lr=3e-4)
scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=num_training_steps)
for step in range(num_training_steps):
    optimizer.zero_grad()
    loss = model(batch)
    loss.backward()
    optimizer.step()
    scheduler.step()

Adapt the snippet to your framework. The same pattern holds for PyTorch Lightning, Hugging Face Trainer, DeepSpeed, Megatron-LM, and vLLM training wrappers. Where the wrapper exposes a config flag (for examplelr_scheduler_type in Trainer), prefer the flag over the imperative API to keep the schedule declarative and reproducible.

Best practices by model family

Model / StackRecommendationNotes
Wedged under peak loadLower the maximum batched token budgetRemoves the tight contention the deadlock state requires.
High concurrency trafficLower the concurrent request limitBounds how many outstanding claims exist against the budget.
Speculative decoding enabledDisable it while diagnosingIt lets an admitted request's demand grow after admission.
Any production poolAlert on zero completions with a non-empty queueLiveness and readiness both pass while the server does nothing.

With the fix vs without the fix

DimensionWith the fixWithout the fix
Overloaded versus wedgedWedged completes nothing at allAssumed to be slow rather than stopped
What health checks reportHealthy, because the process answersExpected to fail and evict the instance
Effect of restartingRestores service until load returnsTaken as evidence the problem is fixed

Real engineering notes

Every health check will report this instance as fine. The process is running, the port answers, and readiness passes, so an orchestrator keeps routing to a server that completes nothing. If you take one thing from this entry, make it the alert on completions rather than on liveness — that gap is what turns a scheduler bug into a silent outage across a whole pool.

Visual fingerprint

Claims that cannot be satisfied or withdrawn
  budget: max_num_tokens
    admitted A  needs more to finish  ---+
    admitted B  needs more to finish  ---+--> total > budget
    admitted C  needs more to finish  ---+

    none can complete    -> none releases budget
    none can be admitted -> queue grows, completions = 0
Each admitted request needs more of the token budget to finish, and none can release any until it does. The total exceeds what the scheduler promised itself, so no request advances and no new request is admitted while the queue keeps growing.

Root cause, fix & prevention

Frequently asked questions

Twelve targeted questions that engineers and on-call staff most commonly ask about this failure.

Is the server just overloaded?
An overloaded server still completes work, slowly. This one completes nothing while the queue grows, which is a deadlock rather than saturation.
Why do health checks pass?
The process is running and the port answers. Nothing in a liveness or readiness probe measures whether requests are completing.
It only happens in production.
The state needs a particular interleaving of admissions and completions under contention, which low-concurrency testing never produces.
Restarting fixes it. Is that enough?
It restores service and nothing more. The state will be reached again at the same load unless the budget or the concurrency limit is reduced.

Don't just read the fix, diagnose your run

The encyclopedia tells you what went wrong. Denpex tells you what went wrong in YOUR training run. With your logs, your config, and your stack.