Skip to content

feat(router): Cold Pool (#12803) as a classifier plugin on the dispatch gate - #205

Draft
ishandhanani wants to merge 30 commits into
idhanani/dyn-queue-dispatch-gatefrom
idhanani/dyn-12803-cold-pool-gate
Draft

ishandhanani wants to merge 30 commits into
idhanani/dyn-queue-dispatch-gatefrom
idhanani/dyn-12803-cold-pool-gate

Conversation

@ishandhanani

Copy link
Copy Markdown
Owner

POC for ai-dynamo#12803: the Cold Pool policy from ai-dynamo#12806, rebuilt as a request-classifier plugin on top of the dispatch gate (ai-dynamo#18443) instead of ~700 lines of core queue changes. Stacked on ai-dynamo#18443's branch.

Summary

Core (generic, no Cold Pool knowledge; +180/-20):

  • ClassifyRequest::set_lane(name): a sessionless request waits in a classifier-named lane instead of the class's shared lane, so a held or constrained head blocks only its own lane. Session lanes take precedence.
  • ClassifyRequest::set_allowed_workers(set): narrows the request's allowed workers (intersected with the caller's); the queue's capacity check and selection both honor it.
  • ClassifyRequest::cached_tokens(): best cached prefix, for effective-prefill decisions.

Plugin request_classifier: cold_pool (lib/router-plugins/builtin/src/cold_pool.rs):

  • Warm / Cold / unmanaged classification by effective prefill (same rule as feat(kv-router): add cold pool routing ai-dynamo/dynamo#12806).
  • Pool = top-N workers by rendezvous hash of worker ID, capped at a third of the workers.
  • Cold: allowed workers = pool, own lane; the gate holds it until a member has no active Warm work, then Go { target: that member }; after soft_drain_timeout_ms, any member.
  • Warm, while Cold is pending: allowed workers exclude the pool; while Cold only runs: exclude just the workers running it (decided at classify time).
  • workers: 0 keeps classification and the lane without containment, for baselines.
default_policy_class: default
policy_classes:
  - {name: default, quantum: 4096, queue_policy: fcfs, prefill_busy_threshold_frac: 1.0}
request_classifier:
  type: cold_pool
  parameters: {request_threshold: 98304, workers: 2, soft_drain_timeout_ms: 20000}

Validation

  • cargo test -p dynamo-kv-router --lib: 1241 passed, including a_classifier_lane_keeps_a_held_head_out_of_the_shared_lane (negative control: without a lane, a held sessionless head blocks the shared lane).
  • cargo test -p dynamo-router-policies: all suites, including 7 Cold Pool unit tests and tests/cold_pool_gate.rs (one policy class through LocalScheduler: Cold held until a member drains while Warm avoids the pool; Cold takes a busy member after the timeout).
  • cargo test -p dynamo-llm --lib -- routing_host::tests: 80 passed.
  • cargo clippy -D warnings on kv-router, router-policies, llm; cargo fmt.

GPU pilot (earlier revision with a gate-side worker-set decision, same policy semantics)

8x H200, 8 SGLang TP1 workers of GLM-4.7-Flash, agg, KV router; Warm = agent-loadgen SWE-bench, 320 agents, 15 min; Cold = 160K-token random (cache-miss) prompt every 20 s from minute 2. Baseline = same classifier with workers: 0.

Warm baseline Cold Pool
ITL p99 (per-request avg) 795 ms 248 ms (-69%)
ITL mean 116 ms 102 ms (-13%)
TTFT mean / p95 0.67 / 2.37 s 0.65 / 2.11 s
E2E mean / p99 20.9 / 159 s 19.2 / 143 s
input tok/s 219K 241K (+10%)
Cold baseline Cold Pool
workers used 8 2 (pool)
TTFT median 47.5 s 129 s
E2E median 54 s 378 s

The Cold arrival rate exceeded the 2-worker pool's capacity (~2.4 workers of Cold prefill demand), so Cold queued without bound. Follow-up runs at sustainable Cold rates are pending.

Gaps found

A request classifier can now return a DispatchGate. The scheduler actor asks
the gate about each queue head before its own capacity check: Go dispatches
with an optional soft worker target, Hold keeps the head queued until a
capacity change, a recheck from the gate, or the time the gate names. The
gate's dispatched callback runs once, when the head leaves the queue.

Queue lanes generalize from per-worker to a packed LaneKey: worker lanes as
before, plus session lanes assigned while a gate is installed, so a held
session head blocks only its own session instead of the class's shared lane.
Session lanes are rechecked on any capacity change and on gate rechecks.

Adds test-only constructors (ClassifyRequest::for_test,
WorkerCandidates::empty) under the bench feature so plugin crates can test
classify directly.

Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
The classifier no longer holds requests in a private queue: classify records
the turn and returns, the gate answers per queue head from the program ledger,
and lifecycle events settle the ledger and wake the queue. The scheduler tick,
per-session waiting lists, arrival order, wakeup handles, and cancel-on-drop
are gone; the router queue owns the waiting turns in per-session lanes.

Rules kept: one turn in flight per session, continuing turns always go,
paused and new programs need room (resume ceiling for paused, full capacity
for new), forced dispatch after resume_timeout_seconds measured from the
turn's registration, pause smallest acting programs over the threshold down to
the target, greedy resume into headroom, hard-pin room check, retention
expiry, and the program limit with idle eviction. scheduler_interval_seconds
is removed from the configuration.

Adds an end-to-end test that drives the real plugin through LocalScheduler.

Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
classify now returns Result<ClassifyRequest, _> instead of a 'static future.
A classifier reads the request and worker rows, writes its overrides, and
returns under the classifier lock; it can no longer park a request before the
queue. Policies that must wait return a DispatchGate, so the router queue
owns every wait: order, class budget, expiry, lanes, and wakeups.

Removes ClassifyFuture, the future-polling and cancellation paths in the
classifier runtime, and the tests that depended on a suspended classification.
The routing-host pause test now holds through the gate with the queue
threshold enabled, which also covers gate installation through the real
router. Docs follow: the admission guide describes queue-or-reject
classification plus the gate, and the examples are synchronous.

Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
Pinned requests no longer get a lane of their own. A class queue has one
shared lane plus, while a dispatch gate is installed, one lane per session.
A pinned head whose worker is full now holds the shared lane until that
worker frees, by design: a request that already has a worker takes no lane
that could look like a fast path, and the only lanes that remain exist so a
gate hold on one session blocks only that session.

WorkerPlacement is gone; enqueue takes Option<SessionLane>. Capacity updates
recheck every blocked lane instead of the changed worker's lane, which drops
the per-worker recheck path and the worker-identity bookkeeping around it.
The three actor tests that relied on pinned lanes now run their pinned
requests in session lanes behind an always-go gate, which keeps the lane
semantics covered; the policy queue lane tests use session lanes throughout.
Docs follow in the scheduling guide, the DRR page, and the tuning page.

Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
…pacity

The dispatch predicate returned true for a class without busy thresholds
before it asked the gate, and the gate's recheck handle was a no-op when no
class queued. With the documented ThunderAgent launch, which sets no queue
threshold, the gate was never asked: no pause or resume hold, no one turn in
flight per session, no room check.

The predicates now ask the gate first and skip only the capacity check for
such classes. The actor always listens on a wake channel, shared with
capacity updates when a class queues and its own otherwise, so the gate's
recheck wakes it in every configuration. A ThunderAgent test runs the
documented config without a threshold and checks a session's second turn is
held until the first completes.

Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
The bench still imported WorkerPlacement, which the worker-lane removal deleted, so clippy --all-targets failed. Its per-lane queues now use session lanes.

Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
With worker lanes gone, a pinned request outside a session lane waits in its class's shared lane and holds it while its worker is full. The tuning page now says so in the queueing section and in the hard affinity mode row, and lists a dispatch gate hold as a reason a class queues.

Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
A class with a zero queue limit never queues, so its requests are admitted without asking the gate, and ThunderAgent commits them at Sent. The install_dispatch_gate comment claimed every class was gated; it and the admission-control guide now state the exception. The selection-trace doc comment, which had been left above install_dispatch_gate, moves back to set_selection_trace.

Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
Since the actor always listens for wakes, its capacity_updates was always Some outside tests. Drop the Option and the unreachable plain-recv and is_some branches in the run and refresh loops.

Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
drain_ready seeds its recheck cell with the actor's current recheck time, so gate_allows's own min-merge keeps the earliest one and note_gate_recheck goes away.

Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
The OpenGate helpers were inserted between the comment and the function it describes.

Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
A gate that returned Hold with a past until re-armed the actor's recheck
timer, which fired at once, asked the gate again, and looped at full CPU.
Ignore a deadline that is not in the future and say so on Hold.

Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
The example kept returning Hold with an until in the past after max_hold,
which arms no timer and holds the head forever.

Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
has_dispatchable_ready_head asked the gate with a throwaway recheck cell,
so a Hold until for a lane root exposed by cancellation never armed the
actor's timer. Write it back as drain_ready does.

Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
…event

Retention expiry ran only when a dispatch or lifecycle event settled the
ledger, so in a quiet period an expired idle program kept its charge and
a held session waited out its full forced-dispatch time. check now counts
programs past retention as gone (still a read; the next settle removes
them) and holds no later than the next retention deadline.

Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
… program

sent returned true on every event, so each admitted turn reconciled the
ledger and woke the queue a second time to rescan every held lane. The
selector usually honors the gate's target, which changes nothing.

Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
The parameter shipped in v1.6.1 and now fails startup as an unknown
field; record the removal and its migration on the deprecations page.

Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
The outer Option only recorded that the gate had said Go, and the one
reader flattened it away.

Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
The handle's optional capacity channel is the actor's wake channel when a
class queues on capacity; create one and share it instead of cloning an
Option and filling the gap.

Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
schedule_retention_expiry repeated the idle-program check and deadline
math that retention_deadline already does.

Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
…resholds

Capacity frees were recorded only when some class queues on capacity, so
in a profile with no busy thresholds a gate head held for room was never
asked again on a free, contrary to the Hold contract. Record them on the
actor's wake channel once a gate is installed.

Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
A Sent for a request the gate never saw (a class that never queues)
marked it in flight but created no program, so the session went
uncharged, and an ungated final turn left its program until retention.
Commit it through dispatched with the chosen worker, as a gated turn is.

Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
… gate

Removing the worker lanes left a hard-pinned head on a full worker holding
the class's shared heap, so one bound session stalled unpinned requests that
had idle workers. The replay router's own queue did the same, which failed
native_affinity_waits_for_dispatch_and_queues_only_on_its_bound_rank in CI.

The lane a request waits in no longer depends on whether a dispatch gate is
installed: every request with a session context gets its session's lane, in
the live actor and in offline replay. A session bound to a busy rank, by hard
affinity or by a gate hold, now delays only its own later turns. Requests
without a session keep sharing the class lane, and a sessionless pin that
waits there still holds that lane until its worker frees, as documented.

Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
…talled

update() returned early whenever no class queued on capacity, so the
periodic tick and worker-config changes never rechecked heads a gate
held in that profile.

Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
Session identifiers come from clients, and FxHasher is unkeyed, so a
client could pick an identifier that lands in another session's lane and
share its order and holds. Hash with a RandomState fixed once per
process, as the scheduling guide asks for externally controlled keys.

Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
…recations

classify now returns its result directly and ClassifyFuture is gone, so
custom classifiers need code changes beyond the import paths; say so and
point classifiers that waited at the dispatch gate.

Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
Every session now gets its own lane, gate or not, and exact-worker lanes
are gone; the DRR page and the scheduling guide's lane tree still said
otherwise.

Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
…workers

Two classify-time overrides for policies that hold or constrain some
requests on the dispatch gate:

- ClassifyRequest::set_lane: a request without a session waits in the
  lane its classifier named instead of the class's shared lane, so a
  head the gate holds, or whose workers are full, blocks only its own
  lane. Session lanes still take precedence.
- ClassifyRequest::set_allowed_workers: narrows the request's allowed
  workers (intersected with the caller's); the queue's capacity check
  and worker selection both honor it. An empty intersection keeps the
  caller's set.

Also exposes ClassifyRequest::cached_tokens, the best cached prefix a
classifier needs for effective-prefill decisions.

Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
Ports the Cold Pool policy proposed in ai-dynamo#12803 / ai-dynamo#12806 onto the request
classifier and dispatch gate instead of the core queue:

- Warm/Cold/unmanaged classification by effective prefill.
- Stable rendezvous-hashed membership capped at a third of the workers.
- Cold: allowed workers = the pool, its own lane, and the gate holds it
  until a member has no active Warm work, then targets that member;
  after the soft-drain timeout any member may take it.
- Warm, while Cold is pending: allowed workers exclude the pool; while
  Cold only runs, they exclude just the workers running it.

Configured as request_classifier type cold_pool; workers: 0 keeps the
classification and lane without containment, for baselines.

Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
steer_warm: false keeps Warm on every worker. With soft_drain_timeout_ms: 0 the policy reduces to pool-only: Cold is limited to the pool and waits in its own lane, and nothing else changes.

Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>

This branch had an error being deployed

1 failed deployment
external_collaborator — 715744ad Deployed Nov 10, 2026 by ishandhanani via ok-to-test #562
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.

1 participant