Skip to content

[router] cache_aware_zmq: load-aware selection from engine LoadSnapshot - #28600

Draft
Kangyan-Zhou wants to merge 1 commit into
sgl-project:mainfrom
Kangyan-Zhou:sgl-router-load-aware-select
Draft

Kangyan-Zhou wants to merge 1 commit into
sgl-project:mainfrom
Kangyan-Zhou:sgl-router-load-aware-select

Conversation

@Kangyan-Zhou

@Kangyan-Zhou Kangyan-Zhou commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Motivation

The cache_aware_zmq policy 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 LoadSnapshot on 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_info advertisement.

Modifications

Discovery. Workers advertise load_endpoint_port_base in /server_info's kv_events block. It is Option<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::Load registry opens one SUB per advertised DP rank on load_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 is omit_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_fresh sums 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. WorkerLoads is built once per select() so every comparison in one decision sees a consistent view, and load_of returns engine-reported depth where a fresh snapshot exists and Worker::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:

  • no advertised load port → no load sockets opened;
  • an undecodable frame → logged and dropped, the subscriber survives;
  • entries older than the freshness window, or an incomplete rank set → the worker is omitted from the table;
  • worker removal clears its entries so a re-added worker cannot inherit stale load.

Known gap

load_of can 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_fresh is computed once per selection so per-worker lookups are O(1); it does allocate per worker on the routing path, which is a candidate for Arc<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 overriding active_load, the matched-set tiebreak using it, and stale load falling back; clearing a removed worker; and the /server_info introspection path both with and without the load port advertised.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@Kangyan-Zhou
Kangyan-Zhou force-pushed the sgl-router-load-aware-select branch 3 times, most recently from dbf1a48 to f2874a3 Compare June 18, 2026 05:42
@Kangyan-Zhou
Kangyan-Zhou force-pushed the sgl-router-load-aware-select branch from f2874a3 to 64124fe Compare August 12, 2026 21:39
@github-actions github-actions Bot added the npu label Aug 12, 2026
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
Kangyan-Zhou force-pushed the sgl-router-load-aware-select branch from 64124fe to d0d7e4c Compare August 12, 2026 22:18
@Kangyan-Zhou Kangyan-Zhou changed the title [router] cache_aware_zmq: load-aware selection from engine LoadStat [router] cache_aware_zmq: load-aware selection from engine LoadSnapshot Aug 12, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant