[router] cache_aware_zmq: load-aware selection from engine LoadSnapshot - #28600
Draft
Kangyan-Zhou wants to merge 1 commit into
Draft
Kangyan-Zhou wants to merge 1 commit into
Kangyan-Zhou wants to merge 1 commit into
Conversation
Contributor
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
5 tasks
Kangyan-Zhou
force-pushed
the
sgl-router-load-aware-select
branch
3 times, most recently
from
June 18, 2026 05:42
dbf1a48 to
f2874a3
Compare
Kangyan-Zhou
force-pushed
the
sgl-router-load-aware-select
branch
from
August 12, 2026 21:39
f2874a3 to
64124fe
Compare
The cache-aware-zmq policy infers per-worker load from a router-side in-flight counter, which counts requests this router sent rather than work the engine actually has queued. Subscribe to the engine's load socket and route on its reported queue depth instead. Workers advertise load_endpoint_port_base in /server_info's kv_events block; a Load-kind subscriber opens one SUB per advertised DP rank on that range and feeds EngineLoadTable, keyed (worker_url, dp_rank). The policy reads the freshest per-worker aggregate once per selection. The payload is the engine's LoadSnapshot -- the same struct that backs /v1/loads and DP balancing -- decoded from its msgpack map by field name. Every field defaults to zero because the Python side is omit_defaults, and unknown keys are ignored: the snapshot carries far more than this policy reads and will keep growing, so tolerating both is what stops an engine-side addition from becoming a router-side decode failure. The cost of that permissiveness is that absent and zero are indistinguishable, so a producer-side rename would report every worker as idle rather than failing -- the engine's wire-shape test is what guards it. A worker is trusted only when it reports a full, fresh complement of ranks. Freshness alone is not enough: an entry that does not exist cannot be judged stale, so a rank whose publisher failed to bind at startup would leave the worker reporting a partial sum forever -- looking idle and drawing more traffic precisely because it is broken. Workers are recorded with the rank count they advertised, giving the read path a denominator; a worker that never advertised a load topic has no denominator and is taken at face value. Degradation is deliberate elsewhere too. A worker advertising no load port opens no load sockets. An undecodable frame is logged and dropped rather than killing the subscriber. In all these cases selection falls back to the in-flight counter, which is the prior behavior. Covers the primary /server_info path: the introspector threads load_endpoint_port_base, and dropping it there would have left every worker looking like it predates load publishing with nothing failing. The backward-compatible half -- an older engine omitting the key entirely -- must still parse and leave cache-aware routing working. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MDAQnTUKLAPN5yTi9PawtA
Kangyan-Zhou
force-pushed
the
sgl-router-load-aware-select
branch
from
August 12, 2026 22:18
64124fe to
d0d7e4c
Compare
Kangyan-Zhou
pushed a commit
to Kangyan-Zhou/sglang
that referenced
this pull request
Aug 12, 2026
…ed PUB socket Load-aware routers infer a worker's load from a router-side in-flight counter. That counter measures what one router dispatched, not what the engine has queued: it cannot see other routers or direct clients, and for streaming responses it stays held for the whole response rather than for the time the request occupies the scheduler. The engine already builds the right number. SchedulerLoadInquirer.get_loads() produces a LoadSnapshot every publish cycle for /v1/loads and DP-attention dispatch; this exposes that same snapshot to out-of-process consumers. The router-side consumer is sgl-project#28600. PubLoadSnapshotWriter joins the existing writer family. It is not a variant of ZmqLoadSnapshotWriter: that one is PUSH and connects to the single reader process owning the PULL end, so an extra consumer would load-balance snapshots away from the DP controller rather than receive copies. Routers need fan-out, so this binds PUB. Subscribers require a three-frame message -- topic, big-endian i64 seq, msgpack payload -- and drop anything else; ZMQ_CONFLATE would suit a gauge but keeps only a single frame, so a small send HWM bounds the backlog instead. SchedulerLoadPublisher owns when each sink publishes, because the two want different things. The internal writer is iteration-throttled and forced on prefill and idle, which is right for consumers reading local state. The router-facing socket is bounded in wall-clock seconds: an iteration count stops being a rate limit exactly when iterations stop being work, and an idle scheduler spins its loop freely, so an iteration-based cadence would put an unchanged gauge on the wire at loop rate -- with the cost landing on every subscribed router. What the two do share is the snapshot, which is the part worth coupling: get_loads walks the running batch, the waiting queue and four disaggregation queues, so it runs at most once per call however many sinks are due. SchedulerLoadInquirer stays a pure collector. --load-publish-endpoint sets the port range outright; it defaults to the dp_size ports immediately after the KV-event range. Consumers read the base from /server_info rather than deriving it, so the adjacency is an allocation default rather than a contract, and overriding it costs nothing. That matters because adjacency silently reserves ports an operator may have earmarked -- a replay_endpoint, or a co-located engine -- and it is what forces the range to decline for ipc:// or concrete-host KV endpoints that could otherwise publish load perfectly well. Either way the range resolves through one function, shared with the /server_info advertisement, so the engine cannot advertise a range it will not bind: a router subscribing to a port nobody bound waits forever while counting the worker as an expected publisher. It declines a concrete host, which would be connected to rather than bound -- nothing listens on the load range, so connecting publishes into a void neither side can detect. Failure handling is asymmetric on purpose. Losing the internal writer degrades /v1/loads and DP dispatch and is logged as such; the router-facing socket is an optional extra whose absence routers already know how to survive. Repeated failures are reported on the first occurrence and then at most once per minute, bounded in seconds for the same reason the router cadence is. Tests pin the three-frame layout against the module's own decoder, the advertised-vs-bound port agreement in both directions, every declined-endpoint case, that a usable config actually yields a router writer, and the publisher's contract -- single collection per call, the two cadences including that force cannot bypass the wall-clock floor, per-sink fault isolation, and the failure throttle. A wire-shape test fails if LoadSnapshot is ever declared array_like or a field renamed, either of which would otherwise break every subscriber while leaving the suite green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MDAQnTUKLAPN5yTi9PawtA
Kangyan-Zhou
pushed a commit
to Kangyan-Zhou/sglang
that referenced
this pull request
Aug 13, 2026
…ed PUB socket Load-aware routers infer a worker's load from a router-side in-flight counter. That counter measures what one router dispatched, not what the engine has queued: it cannot see other routers or direct clients, and for streaming responses it stays held for the whole response rather than for the time the request occupies the scheduler. The engine already builds the right number. SchedulerLoadInquirer.get_loads() produces a LoadSnapshot every publish cycle for /v1/loads and DP-attention dispatch; this exposes that same snapshot to out-of-process consumers. The router-side consumer is sgl-project#28600. PubLoadSnapshotWriter joins the existing writer family. It is not a variant of ZmqLoadSnapshotWriter: that one is PUSH and connects to the single reader process owning the PULL end, so an extra consumer would load-balance snapshots away from the DP controller rather than receive copies. Routers need fan-out, so this binds PUB. Subscribers require a three-frame message -- topic, big-endian i64 seq, msgpack payload -- and drop anything else; ZMQ_CONFLATE would suit a gauge but keeps only a single frame, so a small send HWM bounds the backlog instead. SchedulerLoadPublisher owns when each sink publishes, because the two want different things. The internal writer is iteration-throttled and forced on prefill and idle, which is right for consumers reading local state. The router-facing socket is bounded in wall-clock seconds: an iteration count stops being a rate limit exactly when iterations stop being work, and an idle scheduler spins its loop freely, so an iteration-based cadence would put an unchanged gauge on the wire at loop rate -- with the cost landing on every subscribed router. What the two do share is the snapshot, which is the part worth coupling: get_loads walks the running batch, the waiting queue and four disaggregation queues, so it runs at most once per call however many sinks are due. SchedulerLoadInquirer stays a pure collector. --load-publish-endpoint sets the port range outright; it defaults to the dp_size ports immediately after the KV-event range. Consumers read the base from /server_info rather than deriving it, so the adjacency is an allocation default rather than a contract, and overriding it costs nothing. That matters because adjacency silently reserves ports an operator may have earmarked -- a replay_endpoint, or a co-located engine -- and it is what forces the range to decline for ipc:// or concrete-host KV endpoints that could otherwise publish load perfectly well. Either way the range resolves through one function, shared with the /server_info advertisement, so the engine cannot advertise a range it will not bind nor bind one it will not advertise: a router subscribing to a port nobody bound waits forever while counting the worker as an expected publisher. It declines a concrete host, which would be connected to rather than bound -- nothing listens on the load range, so connecting publishes into a void neither side can detect -- and it refuses any range overlapping the kv-events publish or replay sockets, whose own binds run later and unguarded, so taking one of their ports would kill startup blaming the KV publisher. That resolution lives in disaggregation/kv_events.py, next to the config that defines it and the sockets it must avoid: "which ports does this kv-events config occupy" is knowledge that module already owns. load_snapshot.py keeps the transports and their factories, and scheduler_components/load_publisher.py keeps the schedule. Failure handling is asymmetric on purpose. Losing the internal writer degrades /v1/loads and DP dispatch and is logged as such; the router-facing socket is an optional extra whose absence routers already know how to survive. Repeated failures are reported on the first occurrence and then at most once per minute, bounded in seconds for the same reason the router cadence is. Tests pin the three-frame layout against the module's own decoder, the advertised-vs-bound port agreement in both directions, every declined-endpoint case, that a usable config actually yields a router writer, and the publisher's contract -- single collection per call, the two cadences including that force cannot bypass the wall-clock floor, per-sink fault isolation, and the failure throttle. A wire-shape test fails if LoadSnapshot is ever declared array_like or a field renamed, either of which would otherwise break every subscriber while leaving the suite green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MDAQnTUKLAPN5yTi9PawtA
Open
20 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
The
cache_aware_zmqpolicy prices workers by a router-side in-flight counter. That counter reflects what this router dispatched, not what the engine has queued: it cannot see traffic from other router replicas or from direct clients, and for streaming responses it stays held for the full response rather than for the time the request occupies the scheduler.With #28599 the engine publishes its
LoadSnapshoton a dedicated socket. This subscribes to it and routes on real queue depth, keeping the in-flight counter as the fallback for any worker whose reported picture is missing or incomplete.Depends on #28599 for the publisher and the
/server_infoadvertisement.Modifications
Discovery. Workers advertise
load_endpoint_port_basein/server_info'skv_eventsblock. It isOption<u16>: an engine that does not publish load simply omits it, and such a worker opens no load sockets and keeps using the router-side signal. Both the introspector and the discovery fallback thread the field.Subscription. A
SubKind::Loadregistry opens one SUB per advertised DP rank onload_port_base + dp_rank, subscribe-all, sharing the existing pump channel. It is a separate registry from the KV one, each with its own handle map, so the two never collide despite sharing the(url, dp_rank)key.Decoding. The payload is the engine's
LoadSnapshot, a msgpack map keyed by field name. Every field defaults to zero — the Python side isomit_defaults— and unknown keys are ignored, because the snapshot carries considerably more than this policy reads (memory, speculative, LoRA, disaggregation and queue sub-structs) and will keep growing. Tolerating both is what stops an engine-side addition from becoming a router-side decode failure.A worker is trusted only when its picture is complete and fresh.
EngineLoadTable::snapshot_freshsums a worker's ranks and omits the worker entirely if any rank is stale or if fewer ranks have reported than the worker advertised. Freshness alone is not sufficient: an entry that does not exist cannot be judged stale, so a rank whose publisher failed to bind at startup would leave that worker reporting a partial sum indefinitely — looking idle and drawing more traffic precisely because it is broken. Workers are recorded with the rank count they advertised, which gives the read path its denominator; a worker that never advertised a load topic has no denominator and is taken at face value.Selection.
WorkerLoadsis built once perselect()so every comparison in one decision sees a consistent view, andload_ofreturns engine-reported depth where a fresh snapshot exists andWorker::active_load()otherwise. Both the imbalance fast-path and the matched-set tiebreak go through it.Degradation
Every failure path lands on the prior behavior rather than an error:
Known gap
load_ofcan mix measurement bases within a single selection: engine queue depth for workers with a complete fresh snapshot, and the router-side in-flight counter for the rest. Those quantities are not interchangeable, so a fleet where only some workers report load can produce a fabricated spread in the imbalance check. Making the choice all-or-nothing per selection is a follow-up.Accuracy Tests
Not applicable — routing policy only, no change to model execution or output.
Speed Tests and Profiling
No benchmark run.
snapshot_freshis computed once per selection so per-worker lookups are O(1); it does allocate per worker on the routing path, which is a candidate forArc<str>keys if it shows up in profiles.Checklist
Tests cover the wire contract and each degradation path: decoding a load frame into
WorkerEvent::Load; ignoring unknown fields and defaulting absent ones; rank sums, staleness, partial freshness, and a worker missing a rank entirely; engine load overridingactive_load, the matched-set tiebreak using it, and stale load falling back; clearing a removed worker; and the/server_infointrospection path both with and without the load port advertised.