Repository navigation
feat(router): enforce busy-reject thresholds in the KV router queue - #16619
Conversation
✅ Dynamo PR CI passed — run 40457088709 (attempt 1) on
|
7901dbb to
8308240
Compare
ishandhanani
left a comment
There was a problem hiding this comment.
The direction is right: the queue is the only place with fresh booked load, and exposing both threshold sets on the plugin contexts closes the DEP's "plugins cannot see either version" gap. The mocker e2e coverage of the 529 cases is the right gate.
The main inline is the per-selection cost of the reject scan when thresholds are configured, measured with the selection_core bench. The rest is consolidation: the same two-field struct defined twice, a field set after construction, and small duplications.
8308240 to
f5d8d5c
Compare
jthomson04
left a comment
There was a problem hiding this comment.
One blocking concern: routing the thresholds drops the engine-reported kv_used_blocks decode signal. One simplification on the provider wiring.
f5d8d5c to
88d192d
Compare
jthomson04
left a comment
There was a problem hiding this comment.
One note on the shared lock this adds to the per-request path.
88d192d to
b341663
Compare
b341663 to
1ce4f07
Compare
128c382 to
a3e14b1
Compare
a3e14b1 to
aa9fe41
Compare
aa9fe41 to
2f09bb3
Compare
jthomson04
left a comment
There was a problem hiding this comment.
Stale docs after the kv_used_blocks split, plus an indent nit.
3ec3377 to
ab65570
Compare
jthomson04
left a comment
There was a problem hiding this comment.
Lazy reject check and routed monitor verified on ab65570.
1f3f2e8 to
12eba5c
Compare
In KV mode, busy-worker rejection ran on a separate path: router load snapshots went to the worker monitor, the monitor compared them against the busy thresholds, and its overloaded set fed back into selection. The KV router's queue now applies the live thresholds itself when it selects a worker. It builds each rank's `LOAD` row with `worker_input_row`, the builder worker selection and classifiers use, and checks it with `BusyThresholds::has_room`. A worker whose ranks are all past the thresholds is skipped; when none remain the request gets AllEligibleWorkersOverloaded (529), as before. POST /busy_threshold updates the same handle and takes effect at the next selection. Once a KV router takes the thresholds over, the worker monitor stops deriving an overloaded set from them. Request-path overload leases still feed the client's overloaded set, which is unioned with the queue's result. Non-KV routing is unchanged. Request classifiers can read both threshold sets that apply to a request and run the same check: `queue_thresholds()`, `queue_thresholds_for(class)`, and `reject_thresholds()` on the classification request. Routing the thresholds no longer mutes the worker monitor. The queue judges the load this router booked; the monitor keeps the decode threshold and applies it only to worker-published load (`kv_used_blocks`, or `active_decode_blocks` when a worker reports it), the signals the queue cannot see: generated tokens, requests from other frontends, and prefill workers that track no active blocks. Scheduler snapshots of booked load neither feed that state nor move its latch once routed. Selection skips a worker that either check marks busy. The thresholds are judged in the pass that projects each rank's load for the request. The load registry keeps each rank's reported capacity beside its load (`set_worker_capacities`, called when the scheduler reconciles worker configs), and the queue hands that pass a `FullRankCounts` that runs `BusyThresholds::has_room` on every rank as its projection is written and counts the ranks without room per worker. `RoutingEligibility` consults only that count, so a worker is skipped when every rank is full, nothing is scanned before selection, no set is merged with the host's overloaded set, and a pinned request rules on one worker. While no rank is full the count is empty and selection does no extra work. A bench-only constructor lets `selection_core` install a reject-threshold provider on a standalone core; those cores run without KV events so `cargo test` does not hold thousands of listener sockets. Plumbing after review: the queue takes its reject-threshold provider as a constructor argument like the other providers, `PolicyClassConfig`'s profile is shared through one `Arc`, and the classifier's uncached-token count comes from `QueueSnapshot`. On the frontend side one `RoutableThresholds` owns the live handle and the routed switch, `BusyThresholds: From<LoadThresholdConfig>` names the vocabulary conversion, and `LoadThresholdHandle` is an `ArcSwap`, so the per-request reads on worker threads are wait-free and a `/busy_threshold` write never stalls them. Part of #16543. Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
12eba5c to
7ae4f08
Compare
Part of #16543 (PR 4 of 5)
Summary
In KV mode, busy-worker rejection ran on a separate path: router load snapshots went to the worker monitor, the monitor compared them against the busy thresholds, and its overloaded set fed back into selection. This PR has the KV router's queue apply the thresholds itself, on the same rows and check as everything else.
kv_used_blocks, oractive_decode_blockswhen a worker reports it), the signals the queue cannot see (generated tokens, requests from other frontends, prefill workers that track no active blocks); scheduler snapshots of booked load neither feed that state nor move its latch once routed. Selection skips a worker that either check marks busy.ActiveSequencesMultiWorker::set_worker_capacities, called whenLocalSchedulerreconciles worker configs), the projection pass hands every rank's projection and capacity to aProjectedLoadObserver, and the queue's observer counts ranks past the thresholds per worker (BusyThresholds::has_room, the check classifiers and the class-queue gate use).RoutingEligibilityconsults only that count, so a worker whose ranks are all past the thresholds is skipped, no catalog is scanned, no set is merged with the host's overloaded set, and a pinned request judges one worker. While no rank is full the count is empty and selection does no extra work. If no eligible worker remains, the request getsAllEligibleWorkersOverloaded(529), as before.POST /busy_thresholdupdates the same handle, and the queue reads it at each selection.WorkerFilterin the selection policy: a custom policy replaces the filter list, and "all filtered" maps to a different error than overload.has_roomthe router runs:queue_thresholds()(the class the request resolves to now, including an override),queue_thresholds_for(name)(another family or class, resolved with this request's uncached tokens), andreject_thresholds()(router-wide, read when classification started). The two sets stay separate because they mean different things: the queue waits on class thresholds and selection rejects on router-wide ones. Selection plugins do not get them yet; that waits for a consumer such as a placement filter.Behavior differences in KV mode: the check runs synchronously at selection on fresh booked load, where the monitor applied it asynchronously after each load snapshot. The monitor's decode latch still clears only once every signal it judges is under the threshold; once routed it judges worker-published load alone, so a stale booked snapshot can no longer hold it.
Validation
reject_thresholds_shed_full_workers_and_reject_when_none_remain: a worker holding 6 of 8 KV blocks is skipped, and the request is rejected once both are full. Fails with the check disabled.classifiers_read_class_and_reject_thresholds: a classifier reads its class's thresholds, another family's for this request's bucket,Nonefor an unknown class, the router-wide ones, and the new class's thresholds afterset_policy_class.routed_busy_thresholds_stay_live_and_hand_booked_load_to_the_queue(routing marks the monitor routed; a/busy_thresholdupdate reaches the routed handle; an encode context does not route),routed_monitor_keeps_only_the_engine_decode_check,engine_only_ignores_scheduler_snapshots_and_keeps_worker_published_load, andengine_only_latch_is_held_and_released_by_worker_published_load_alone.tests/router/test_router_e2e_with_mockers.py): 7 passed:test_mocker_kv_router_overload_529(decode-blocks, prefill-tokens),test_mocker_disagg_router_overload_529(both),threshold_none_disables_rejection,busy_threshold_endpoint(both planes). With the queue-side check disabled, all 4 overload-529 cases fail.cargo test -p dynamo-kv-router --features standalone-indexer --lib: 1181 passed, includingreject_thresholds_follow_the_live_provider_on_prefill_tokens(prefill thresholds, provider flipped at runtime) and the shed test now pinning to the full worker.cargo test -p dynamo-llm --lib -- kv_router:: discovery:: busy_threshold: 696 passed, includingload_threshold_handle_merges_updates_and_shares_stateandbusy_thresholds_from_load_threshold_config.cargo clippy --workspace --all-targets -- -D warnings: clean.LocalScheduler::new/SchedulerQueue::new(noOnceLock, no hidden setter); oneRoutableThresholdsowns the live handle and the routed switch, built before the sender and the monitor;impl From<LoadThresholdConfig> for BusyThresholdsnames the conversion; the profile is shared through oneArc;uncached_tokenscomes fromQueueSnapshot;LoadThresholdHandleis anArcSwap, so the per-request reads on worker threads are wait-free and a/busy_thresholdwrite never stalls them.Stack
Summary by CodeRabbit
New Features
Documentation
selection_core,no_hints_reject_off/on, bench-only constructor installing a provider that sheds nothing; six alternating rounds pinned to one core, medians, per select-and-reserve; A = reject check evaluated per worker inRoutingEligibility, B = judged in the load projection pass): 512 workers 68.1/76.9 us -> 68.1/72.4 us (off/on; configured-threshold cost +12.9% -> +6.2%), 4096 workers 474.6/583.7 us -> 476.4/527.8 us (+23.0% -> +10.8%); thresholds-on alone -5.8% and -9.6%. With no thresholds configured the two are equal within noise. The remaining cost of configured thresholds is thehas_roomarithmetic per rank inside the pass; it no longer re-reads each rank's load.