Skip to content

feat(inference): prefix-affinity routing and a configurable cost policy in the DP coordinator - #6668

Draft
sidsingh-nvidia wants to merge 9 commits into
NVIDIA:mainfrom
sidsingh-nvidia:siddharth/prefix-affinity-routing
Draft

feat(inference): prefix-affinity routing and a configurable cost policy in the DP coordinator#6668
sidsingh-nvidia wants to merge 9 commits into
NVIDIA:mainfrom
sidsingh-nvidia:siddharth/prefix-affinity-routing

Conversation

@sidsingh-nvidia

Copy link
Copy Markdown
Contributor

Stacked PR — read this first. This branch sits on top of #6223 (frontend/SO_REUSEPORT) and #6497 (coordinator wire format). GitHub shows all four commits because a cross-fork PR cannot target a non-main base.

Review only the top two commits:

  • feat(inference): prefix-affinity routing in the DP coordinator
  • feat(inference): make the prefix-caching cost policy configurable

The lower two are under review in #6223 and #6497. I will rebase and retarget once those land.

What

Two things, split so the second is reviewable on its own:

  1. Prefix-affinity routing — the coordinator can route on how much of a request is already cached on each DP rank, not just on queue depth.
  2. A configurable cost policy — how that affinity is weighed against rank load becomes a separate, selectable knob.

The split

The coordinator previously conflated two decisions: which affinity signal to read and how that signal trades off against load. They are now orthogonal.

PrefixCachingCoordinatorPolicy keeps its meaning and picks the signal. Both prefix-aware signals are normalized to the fraction of the request already cached on a rank, in [0, 1]:

Policy Signal
LONGEST_PREFIX contiguous prefix depth / total blocks
FIRST_PREFIX_BLOCK binary first-block hit
LOAD_BALANCED none — fewest in-flight requests, affinity ignored

The new PrefixCachingCostPolicy picks how that fraction is scored, and composes with either prefix-aware signal:

Policy Score (highest wins)
RELATIVE_LOAD_WEIGHTED (default) fraction - beta * (load - mean) / max(1, mean)
FREE_CAPACITY_WEIGHTED alpha * fraction + (1 - alpha) * free_slots / max_requests

FREE_CAPACITY_WEIGHTED is the pre-existing first_prefix_block behaviour, preserved under a name.

Why RELATIVE_LOAD_WEIGHTED is the default

Measuring load against the fleet mean rather than in absolute terms is what makes the penalty behave correctly at both ends:

  • At saturation, every rank is near the mean, the penalty vanishes, and routing is pure affinity — which is what you want, because moving a request to an equally-busy rank buys nothing and costs a cache miss.
  • As the fleet diverges (the drain phase, where imbalance actually hurts), the penalty grows and pulls work toward idle ranks.

Both terms are normalized, so beta is dimensionless: beta = 1.0 means a rank at twice the mean forfeits one full prompt's worth of cache credit. The mean is floored at 1 so a near-idle fleet does not turn a single in-flight request into a large relative load and thrash on noise.

This approximates the stickiness a session-affinity router gets for free — a multi-turn request lands back on the rank holding its history — without needing a session id. The prior multiplicative cost instead charged remaining_blocks * (1 + load), which over-weights load in the tail.

Measured impact

16-replica agentic (SWE) rollout workload, RELATIVE_LOAD_WEIGHTED vs. the previous multiplicative cost:

Metric Before After
Load CV, busy phase 0.434 0.272
Load CV, tail 1.275 0.555
Idle-while-others-busy 15.7% 6.7%
Max/median replica load 2.13 1.44

Prefix-cache skip rate on the same workload rose from 59-63% to 97.7%.

Behaviour matrix

Two replicas, A holds the full prefix, B is colder:

pending relative_load_weighted free_capacity_weighted
[32, 32] A (warm) A (warm)
[20, 10] A (warm) A (warm)
[8, 2] B (idle) A (warm)
[4, 0] B (idle) A (warm)
[0, 0] A (warm) A (warm)

The default diverges only where one rank is meaningfully idle relative to the fleet.

Default change — please flag if this is contentious

prefix_caching_coordinator_policy flips from LOAD_BALANCED to LONGEST_PREFIX, so prefix affinity is used by default once prefix caching is enabled. Runs with prefix caching off are unaffected. Happy to land this behind the old default if reviewers would rather stage it.

New flags

  • --inference-dynamic-batching-prefix-caching-cost-policy (default relative_load_weighted)
  • --inference-dynamic-batching-prefix-caching-load-beta (default 1.0)

--inference-dynamic-batching-prefix-caching-routing-alpha is unchanged but now documented as applying only under free_capacity_weighted.

sidsingh-nvidia and others added 9 commits August 3, 2026 13:42
The replicas shared one listening socket: the parent bound it and passed the
same fd to every forked worker, so all of them accepted from a single queue.
That does not balance. Whichever worker is already running tends to win the
wakeup, and it keeps winning, because an event loop with work in flight polls
more often than one blocked in accept. SO_REUSEPORT was set on that socket but
was inert -- the kernel only load-balances when several sockets are bound to
the port and it can hash a connection's 4-tuple to choose between them.

Measured with 32 replicas and a fresh connection per request, ~90% of traffic
landed on 5 of them, 20 replicas served exactly one request each, and
throughput was 3.7x lower than the same server under a pooled client that
opened its connections up front. Load made it worse rather than averaging it
out: at 2048 requests the busiest replica took 604x the quietest.

Each replica now binds its own socket on the shared port, so every one gets its
own accept queue. Spread became max/min 1.5x with all replicas serving, and
throughput 2.5x on the fresh-connection path. A pooled client is unaffected in
steady state, which is the point: how well the frontend spreads no longer
depends on connection behaviour the server cannot observe.

start_text_gen_server now returns the base URL it is serving on. Callers that
start a frontend on more than one rank need the addresses to spread requests
over; previously they had to reconstruct them. The signature is otherwise
unchanged, including sock, which still fixes the port -- it is closed rather
than shared, since replicas bind their own.

tools/run_dynamic_text_generation_server.py gains --frontend-on-all-ranks,
which hosts a frontend on every rank and gathers the URLs. Frontend work is
CPU-bound and otherwise confined to one rank's CPU allocation while the rest of
the job's cores go unused.

Signed-off-by: Siddharth Singh <sidsingh@nvidia.com>
(cherry picked from commit 1001ca9)
…is metadata of constant size. This is the only thing that the coordinator needs to unpack/read and pack
Route each request to the rank that already holds the most of its prompt
rather than to the least loaded one. Frontends compute per-block prompt
hashes and ship them alongside the request; the coordinator scores ranks by
(prefill blocks still to compute) x (1 + load), so idle ranks fill first and
a rank holding the prefix wins thereafter.

Hashing happens on the frontend, not the coordinator: the tokens are already
in hand there, frontends run many-to-one against a single serial coordinator
loop, and hashing at the coordinator would mean unpacking the prompt frame
the request/prompt frame split exists to avoid.

Whether to hash follows from the coordinator's routing policy, which is the
only component that knows if anyone will read the hashes. `routes_on_prefix`
lives beside the policy enum so a new prefix-aware policy is a one-line
change. `block_size_tokens` is granularity only and is always passed; it must
match the engine's KV block size or the hashes name blocks the engine never
cached.

Coordinator-side cache tracking assumes an engine still holds a block for
`prefix_cache_ttl_seconds` under the LRU eviction policy, since engine-side
eviction is not observable from the coordinator.

On a 16-engine nanoV3.5 SWE-RL run this moved the prefill skip rate from
59-63% to 97.7% and the prefill share of step time from 42-62% to 10.3%.

Signed-off-by: Siddharth Singh <sidsingh@nvidia.com>
(cherry picked from commit fcb23e1)
The coordinator conflated two decisions: which affinity signal to read
(first-block hit vs contiguous prefix depth) and how that affinity is weighed
against rank load. Split them so the cost function is selectable independently
of the signal.

`PrefixCachingCoordinatorPolicy` keeps its meaning -- it picks the signal --
and both signals are now normalized to the fraction of the request already
cached on a rank, in [0, 1]. The new `PrefixCachingCostPolicy` picks how that
fraction is scored, and composes with either signal:

  RELATIVE_LOAD_WEIGHTED (new default)
      score = fraction - beta * (load - mean) / max(1, mean)
      Load is measured against the fleet mean, so the penalty vanishes while
      ranks are balanced: at saturation this is pure affinity, and load only
      pulls toward idle ranks as the fleet diverges. Approximates the session
      stickiness a session-affinity router gets for free, with no session id.
      The mean is floored at 1 so a near-idle fleet does not turn one in-flight
      request into a large relative load and thrash on noise.

  FREE_CAPACITY_WEIGHTED (the previous first_prefix_block behaviour)
      score = alpha * fraction + (1 - alpha) * free_slots / max_requests
      Fixes the trade-off in absolute terms rather than relative to load.

Also flips the coordinator policy default from LOAD_BALANCED to LONGEST_PREFIX,
so prefix affinity is used by default once prefix caching is enabled.

Measured on a 16-replica SWE rollout workload, RELATIVE_LOAD_WEIGHTED cuts
drain-phase imbalance sharply versus the previous multiplicative cost:
busy-phase load CV 0.434 -> 0.272, tail CV 1.275 -> 0.555,
idle-while-others-busy 15.7% -> 6.7%, max/median replica load 2.13 -> 1.44.

Signed-off-by: Siddharth Singh <sidsingh@nvidia.com>
@sidsingh-nvidia
sidsingh-nvidia requested review from a team as code owners August 19, 2026 18:09
@copy-pr-bot

copy-pr-bot Bot commented Aug 19, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@svcnvidia-nemo-ci
svcnvidia-nemo-ci marked this pull request as draft August 19, 2026 18:12
@github-actions

Copy link
Copy Markdown
Contributor

This PR has been automatically converted to draft because all PRs must start as drafts.

When you are ready for review, click Ready for Review to begin the review process. This will:

  1. Add the oncall reviewer (optional reviewer)
  2. Add required review teams based on your changes

See the contribution guide for more details.

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