Skip to content

epp: hold flow-control queue under saturation via in-flight credit#2066

Open
ruocco wants to merge 3 commits into
llm-d:mainfrom
ruocco:epp-flowcontrol-inflight-credit
Open

epp: hold flow-control queue under saturation via in-flight credit#2066
ruocco wants to merge 3 commits into
llm-d:mainfrom
ruocco:epp-flowcontrol-inflight-credit

Conversation

@ruocco

@ruocco ruocco commented Jul 17, 2026

Copy link
Copy Markdown

The flow controller's dispatch gate (dispatchCycle: saturation >= usageLimit) did not hold back non-sheddable (priority-0) traffic under sustained load: the backend's internal waiting queue grew to hundreds while the EPP flow-control queue stayed near-empty, so the backlog accumulated inside the model server (ordered by the model server) instead of being held in the EPP queue where the fairness and ordering policies apply.

Root cause: the saturation signal is computed from endpoint metrics scraped on a poller (RefreshMetricsInterval, default 50ms), while dispatchCycle runs on a 1ms ticker and dispatches one item per cycle. Between two scrapes the controller issues many dispatches against a stale-low WaitingQueueSize, overshooting the gate before the next scrape reflects them. The utilization detector also aggregated per-endpoint scores with an unweighted average, letting one saturated endpoint be diluted by idle ones.

Fix:

  • Add SaturationDetector.SaturationWithInFlight(ctx, endpoints, inFlight), with the contract Saturation(...) == SaturationWithInFlight(..., 0). The controller passes the count of gate-passing dispatches not yet reflected in scraped metrics; the detector attributes them to the pool's load so the gate holds within one scrape window. Implemented for the utilization and concurrency detectors; all mock implementations updated.
  • Processor tracks inFlightCredit (incremented per dispatch, reset when a fresher metrics sample lands or the pool is empty) and feeds it to the detector. State is touched only by the single Run goroutine, so no synchronization is added.
  • The utilization detector now aggregates endpoint scores with max (roofline at the pool level) instead of an unweighted average, so a single saturated endpoint is no longer diluted by idle ones.

What type of PR is this?

What this PR does / why we need it:

Which issue(s) this PR fixes:

Fixes #

Release note (write NONE if no user-facing change):

The utilization saturation detector now compensates the flow-control gate for scrape lag using the in-flight request count from a configured `inflight-load-producer`, holding backpressure within a single scrape window instead of overshooting between polls. Gate behavior changes only when a producer is configured; without one, the detector relies solely on scraped metrics as before.

The flow controller's dispatch gate (dispatchCycle: `saturation >= usageLimit`)
did not hold back non-sheddable (priority-0) traffic under sustained load: the
backend's internal waiting queue grew to hundreds while the EPP flow-control
queue stayed near-empty, so the backlog accumulated inside the model server
(ordered by the model server) instead of being held in the EPP queue where the
fairness and ordering policies apply.

Root cause: the saturation signal is computed from endpoint metrics scraped on a
poller (RefreshMetricsInterval, default 50ms), while dispatchCycle runs on a 1ms
ticker and dispatches one item per cycle. Between two scrapes the controller
issues many dispatches against a stale-low WaitingQueueSize, overshooting the
gate before the next scrape reflects them. The utilization detector also
aggregated per-endpoint scores with an unweighted average, letting one saturated
endpoint be diluted by idle ones.

Fix:
- Add SaturationDetector.SaturationWithInFlight(ctx, endpoints, inFlight), with
  the contract Saturation(...) == SaturationWithInFlight(..., 0). The controller
  passes the count of gate-passing dispatches not yet reflected in scraped
  metrics; the detector attributes them to the pool's load so the gate holds
  within one scrape window. Implemented for the utilization and concurrency
  detectors; all mock implementations updated.
- Processor tracks inFlightCredit (incremented per dispatch, reset when a fresher
  metrics sample lands or the pool is empty) and feeds it to the detector.
  State is touched only by the single Run goroutine, so no synchronization is
  added.
- The utilization detector now aggregates endpoint scores with max (roofline at
  the pool level) instead of an unweighted average, so a single saturated
  endpoint is no longer diluted by idle ones.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Angelo Ruocco <ang@zurich.ibm.com>
@ruocco
ruocco requested review from a team, LukeAVanDrie and shmuelk as code owners July 17, 2026 12:57
@ruocco
ruocco requested review from elevran and liu-cong July 17, 2026 12:57
@github-actions github-actions Bot added size/L Denotes a PR that changes 100-499 lines, ignoring generated files. kind/bug Categorizes issue or PR as related to a bug. labels Jul 17, 2026
@animeshtrivedi

Copy link
Copy Markdown

cc: @praveingk @vMaroon

@LukeAVanDrie

Copy link
Copy Markdown
Contributor

@ruocco, have you tried using the concurrency-detector instead? That seems better suited for your use case without making any overhauls to the flow control runtime

@ruocco

ruocco commented Jul 20, 2026

Copy link
Copy Markdown
Author

@ruocco, have you tried using the concurrency-detector instead? That seems better suited for your use case without making any overhauls to the flow control runtime

For a very narrow and specific use case, meaning fixing a deployment configuration the traces, I can find a number that approximates this behaviour. It will still sometimes overshoot and sometimes undershoot.

But if we want to have any meaningful impact on production environment, where the deployment and workload can vary, the only way is to have a reliable dynamic way of moving the queue away from vllm and into llm-d.

The dispatch-only inFlightCredit only ever grew: it counted gate-passing
dispatches but never subtracted requests that completed before the next
metrics scrape, so it could overstate load and over-throttle the gate.

The EPP already tracks completion-accurate per-endpoint in-flight requests
via InFlightLoadProducer (incremented at dispatch, decremented on response
EndOfStream). Read that InFlightLoad attribute in the utilization detector
and credit only the requests the scrape has not yet reflected:

  credit = max(0, InFlightRequests - (WaitingQueueSize + RunningRequestsSize))

Subtracting scraped waiting+running avoids double-counting requests already
in the sample; the InFlightLoad decrement means completions are accounted
for. The dependency is optional, so the detector still works on scraped
metrics alone when no inflight-load-producer is configured.

This removes the processor-side inFlightCredit/lastMetricsStamp state and
the SaturationWithInFlight interface method. The concurrency detector reverts
to Saturation-only, as it already reads InFlightLoad directly.

Signed-off-by: Angelo Ruocco <ang@zurich.ibm.com>
@ruocco

ruocco commented Jul 20, 2026

Copy link
Copy Markdown
Author

I've updated the PR, I'm now using the existing inflight-load-producer to account for both dispatched requests before the poll and completed ones.

Without inflight-load-producer the previous behaviour still stands, i.e. the last queue depth polled value is used and there is no compensation for the requests that are dispatched in between the polls.

@animeshtrivedi FYI

@LukeAVanDrie LukeAVanDrie left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sorry for the delay on this, I had some reservations on your first commit which have largely been resolved in the second. Deriving the credit from inflight-load-producer gives it per-request identity, makes the accounting much safer, and neither the controller nor the plugin interface needs to change anymore.

One related update: #1991 merged since your last push, adding a hybrid mode to the concurrency detector (per-endpoint max(requestRatio, tokenRatio), averaged across endpoints). It addresses the dimension-mismatch half of your objection, since whichever axis the workload stresses binds first, so it may be a usable interim config for your deployment. The limits are still static, though, so it doesn't remove the calibration burden this PR targets.

One observation on where this sits architecturally. The PR fuses the two detectors' data sources, with the scrape as primary truth and the EPP-tracked count as a correction. The capacity-ledger proposal in #2061 (docs/flow-control-capacity-ledger.md) ends at the inverse: EPP-tracked accounting becomes primary and the scrape becomes a reconciliation input. Its migration path includes an intermediate step that grows out of exactly these components, inflight-load-producer plus the concurrency detector: track in-flight KV tokens and slots per endpoint, releasing prompt tokens at first token and the rest at end of stream. Your formula stops right at that step's boundary.

The credit corrects the queue term, but unobserved dispatches also consume KV as soon as prefill lands, and compensating that axis needs the per-request token estimates the producer already tracks. Leaving it out here is reasonable, and inverting the roles later stays a localized change. If you have time, I would appreciate your review on #2061.

Two changes I would like to see before this merges:

  1. Drop the average -> max change from this PR. The credit corrects each endpoint's score before aggregation, so it fixes the scrape-lag defect under either aggregation; the PR doesn't need max to deliver its fix. Max has consequences that need their own design. #1991 also just standardized the other shape on the concurrency detector (roofline per endpoint, average across the pool), so pool-level max here would go against that precedent. I understand the dilution defect you're targeting, but I think the fix has to keep staleness handling separate from aggregation and account for routing. Can we create an issue for this and discuss there?
  2. P/D disaggregation breaks the credit as-is. With the default producer config, prefill-profile request counters are held until end-of-stream, so a prefill endpoint shows phantom credit for the full decode duration of every request whose prefill it served.

Follow-up, non-blocking: Filter() still reads raw scraped metrics, so the thundering-herd defect documented in the README (a routing problem) gets none of this benefit while the gate does. The same credit could serve the per-endpoint filter in a separate PR.

Please also add a release note (gate behavior changes when a producer is configured) and file or link an issue for tracking.

if metrics == nil || time.Since(metrics.UpdateTime) > d.config.MetricsStalenessThreshold {
totalScore += 1.0
// Stale/missing metrics are treated as fully saturated (conservative).
maxScore = max(maxScore, 1.0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

With max aggregation the staleness sentinel of 1.0 becomes the pool saturation. One endpoint with missing or stale metrics closes the gate for the whole pool, and also trips the Saturation() >= 1.0 shedding logic for the legacy admission controller. A new endpoint joining before its first scrape hits this during scale-up under load. Under average a stale endpoint cost 1/N. With your change, it takes out the pool. Max also over-throttles on fresh metrics: the scheduler (and this detector's own Filter) route away from the hottest endpoint, so one hot pod gating nine idle ones defeats work conservation.

The credit doesn't depend on this change, since it corrects each endpoint's score before aggregation. I'd revert to average in this PR.

If addressing the skew is still required we can fix that separately. E.g., by excluding staleness from the aggregate while fresh endpoints exist and possibly adapting the aggregate to account for routing. This change also alters Saturation() semantics for consumers outside flow control and for every dashboard on the pool-saturation metric, so on its own it needs a release note and discussion.


qRatio := float64(metrics.WaitingQueueSize) / float64(d.config.QueueDepthThreshold)
// In-flight requests the scrape has not yet observed (see doc comment).
credit := d.inFlightRequests(e.GetAttributes()) -

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In an aggregated pool this formula is sound.

  • Requests in transit to the engine belong in the credit (that is the compensation you want).
  • Completions since the scrape net out, since they decrement the count while the stale Waiting + Running still includes them.
  • Requests that finished generating but haven't hit EndOfStream yet add a small standing bias of roughly completion rate times drain latency, which errs toward holding the gate and is backstopped by the state janitor.

Because every increment has a matching decrement event, nothing should accumulate.

P/D disaggregation is where the assumption breaks. With the default addEstimatedOutputTokens=false, the producer releases only token counters at StartOfStream; the request counter for every profile, including prefill, is held until EndOfStream. A prefill endpoint finishes its physical work at TTFT and its scraped Waiting/Running drop, but its InFlightLoad.Requests stays elevated for the whole decode. Its credit is then roughly the number of requests currently decoding whose prefill it served.

This bias scales with pool throughput instead of a small scalar constant, saturates the qRatio at the default threshold of 5, and with your max aggregation approach can hold the pool gate closed under sustained decode load.

I'd fix this in the producer rather than here (and maybe as an independent PR). Release the prefill profile's request counter at StartOfStream in both config paths. The addEstimatedOutputTokens=true path already does this via release(), and prefill is physically complete at first token. It would also correct the concurrency detector's view of prefill pods. The alternative is documenting that the credit assumes aggregated (non-P/D) pools.

^ I am happy to create an issue for this and get other opinions here.


**Scrape-lag compensation:** `WaitingQueueSize` is scraped on a poller while the Flow Controller's dispatch loop runs far faster, so between two scrapes the queue term is stale-low and the gate would let the controller over-dispatch. When an `inflight-load-producer` is configured, the queue term is corrected by the in-flight requests the scrape does not yet reflect:

Credit = max(0, InFlightRequests - (WaitingQueueSize + RunningRequestsSize))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: I think some additional content would help here. The subtraction is only sound when the metrics mapping defines runningRequestsSpec (defaults exist for vLLM and SGLang); a custom mapping without it leaves RunningRequestsSize at 0 and the credit over-counts by the running count.

And InFlightRequests is per EPP replica while Waiting + Running is engine-global, so with multiple EPP replicas the credit floors at 0 and the compensation silently disappears. That is the same single-writer assumption the concurrency detector makes, but it should be stated, especially as there has been an influx in multi-replica saturation detector issues resulting from batch gateway benchmarking.

@LukeAVanDrie

Copy link
Copy Markdown
Contributor

@asharkhan3101 Could I get your perspective on this PR (and my capacity ledger design in #2061 if you have time)? I want to make sure the detectors are converging on the same north star for capacity management. One concrete tension: #1991 settled on per-endpoint roofline aggregated by pool average, while this PR moves the utilization detector to a pool-level max we should pick one shape deliberately rather than diverge by accident.

These are tricky to review because their major effects are on workload dynamics that aren't apparent from code inspection. The eviction proposal in #2061 already commits to a closed-loop simulation harness (synthetic pool, configurable sensor lag, burst shapes) for the reclamation controller. I'm wondering whether we should generalize that into a traffic-flow simulation for saturation detection broadly, so changes like this one can be evaluated during development instead of against production traces.

@github-actions

Copy link
Copy Markdown
Contributor

🚨 Unsigned commits detected! Please sign your commits.

For instructions on how to set up GPG/SSH signing and verify your commits, please see GitHub Documentation.

@github-actions github-actions Bot added kind/bug Categorizes issue or PR as related to a bug. and removed kind/bug Categorizes issue or PR as related to a bug. labels Jul 24, 2026
Revert the utilization detector's pool aggregation from max back to the
average it had before this PR. The scrape-lag credit corrects each
endpoint's score before aggregation, so the fix holds under average; max
changes Saturation() semantics for other consumers and warrants its own
design, tracked separately.

Document the credit's assumptions in the README and Saturation doc comment:
it requires runningRequestsSpec to be mapped, a single EPP replica
(InFlightRequests is per-replica while waiting+running is engine-global),
and aggregated (non-P/D) pools, since the default producer holds the
prefill request counter until end-of-stream.

Signed-off-by: Angelo Ruocco <ang@zurich.ibm.com>
@ruocco

ruocco commented Jul 24, 2026

Copy link
Copy Markdown
Author

@LukeAVanDrie Thanks for the thorough review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kind/bug Categorizes issue or PR as related to a bug. size/L Denotes a PR that changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants