Skip to content

[scheduler] Publish per-scheduler load on a dedicated socket for load-aware routers - #28599

Draft
Kangyan-Zhou wants to merge 1 commit into
sgl-project:mainfrom
Kangyan-Zhou:kv-events-loadstat-publish
Draft

Kangyan-Zhou wants to merge 1 commit into
sgl-project:mainfrom
Kangyan-Zhou:kv-events-loadstat-publish

Conversation

@Kangyan-Zhou

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

Copy link
Copy Markdown
Collaborator

Motivation

Load-aware routers currently infer a worker's load from a router-side in-flight counter. That counter measures what this router has dispatched, not what the engine actually has queued — it misses traffic from other routers and direct clients, and for streaming responses it stays held for the whole response rather than the time the request occupies the scheduler.

The engine already builds exactly the right number. SchedulerLoadInquirer.get_loads() produces a LoadSnapshot every publish cycle, which today feeds /v1/loads and DP-attention dispatch. This exposes that same snapshot to out-of-process consumers, so a router can price workers on real queue depth.

The router-side consumer is #28600.

Modifications

A third writer in the existing load-snapshot family. PubLoadSnapshotWriter joins ShmLoadSnapshotWriter and ZmqLoadSnapshotWriter in managers/load_snapshot.py, behind the same write() / publish_interval / close() interface. It is not a variant of the existing ZMQ writer: that one is PUSH and connects to the single reader process that owns the PULL end, so an additional consumer would load-balance snapshots away from the DP controller rather than receive copies. Routers need fan-out, so this binds PUB.

Collection and publication get one owner. SchedulerLoadInquirer now holds the writers and gains publish(); Scheduler.publish_load_snapshot is deleted and its two call sites delegate. The snapshot is built once per cycle no matter how many writers are due — relevant because get_loads() walks the running batch, the waiting queue and four disaggregation queues.

Port derivation and advertisement share one decision. load_pub_port_base() decides whether a load range exists and where it starts (kv_events_port + dp_size; load rank r uses base + r). Both the writer and /server_info's load_endpoint_port_base route through it, so the engine cannot advertise a range it will not bind — a router subscribing to a port nobody bound waits forever while reporting the worker as an expected publisher.

It declines, and the advertisement is omitted, when:

  • there is no kv-events config, or the publisher is null;
  • the endpoint is not tcp://ipc:// and inproc:// serve KV events fine but have no port to offset;
  • the endpoint has a concrete host, which the publisher would connect to rather than bind; nothing listens on the load range, so connecting publishes into a void neither side can detect;
  • the range would run past the u16 ceiling.

A router treats a missing advertisement as "this engine does not report load" and falls back to its own signal, so declining is a supported outcome rather than an error.

Wire framing. Subscribers require a three-frame message — [topic, big-endian i64 seq, msgpack payload] — and drop anything else. ZMQ_CONFLATE would be the natural fit for a gauge but keeps only a single frame, which would corrupt that layout; a small send HWM bounds the backlog instead. The topic frame is empty: this socket carries only load, so subscribers subscribe-all.

Failure handling is asymmetric on purpose. Each writer is constructed under its own guard, so a bad router-facing endpoint cannot cost the internal writer. Losing the internal writer is logged as an error — without it /v1/loads omits the rank and the DP controller stops refreshing its budget for it, so the rank drifts out of the dispatch rotation with no other symptom. Publish failures are counted and reported on the first occurrence and every Nth after: the idle path publishes on every scheduler-loop iteration and --sleep-on-idle is off by default, so an unthrottled warning would turn a permanently broken writer into thousands of lines per second.

Notes for reviewers

  • Removed override point. Scheduler.publish_load_snapshot was public; forks overriding it will need to move to SchedulerLoadInquirer.publish.
  • Port footprint. --kv-events-config now reserves 2 * dp_size consecutive ports from the configured base rather than dp_size. Co-located engines spaced dp_size apart will collide. The argument documentation is not yet updated for this.
  • No new server arguments; cadence reuses --load-snapshot-publish-interval.

Accuracy Tests

Not applicable — no changes to model execution or output.

Speed Tests and Profiling

No benchmark run. The one relevant change is a reduction: publishing to N sinks now collects a single snapshot per cycle rather than one per sink, which removes a redundant queue walk on every extend batch.

Checklist

Tests added in test/registered/unit/managers/test_load_snapshot_backends.py:

  • the three-frame layout, against the module's own decoder;
  • advertised port equals bound port, in both the accept and decline directions;
  • the factory returns both writers, in an order that keeps the internal one from hiding behind the optional one;
  • publish() — single collection per cycle, independent per-writer intervals, force semantics, per-writer fault isolation, and the failure throttle;
  • the encoder's wire shape is a map keyed by field name, so declaring array_like=True on LoadSnapshot or renaming a field fails here rather than silently breaking consumers.

CI States

Latest PR Test (Base): ❌ Run #31657253640
Latest PR Test (Extra): ❌ Run #31657253406

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces periodic runtime load snapshots (LoadStat) published on a distinct ZMQ topic (LOAD_TOPIC) over the same socket as KV-cache events, allowing load-aware routers to read the true queue depth and KV occupancy. It updates the event publisher to support topic overrides with independent sequence streams and adds a wire-contract test to ensure the msgpack array shape matches expectations. The review feedback suggests using dataclasses.field(init=False, repr=False) for the internal counter fields in SchedulerKvEventsPublisher to avoid exposing them in the autogenerated constructor and representation methods.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +72 to +76
# Throttle counter for publish_load_stat (slots dataclass: must be declared).
_load_publish_counter: int = 0
# Consecutive publish_load_stat failures, reset on success. Drives the
# periodic re-warn (see LOAD_PUBLISH_FAIL_WARN_EVERY).
_load_publish_fail_count: int = 0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since SchedulerKvEventsPublisher is a dataclass, declaring _load_publish_counter and _load_publish_fail_count as standard fields with default values means they will be included in the autogenerated __init__ signature, __repr__, and __eq__ methods. Since these are internal/private state variables, they should be excluded from the constructor and representation by using dataclasses.field(default=0, init=False, repr=False).

Suggested change
# Throttle counter for publish_load_stat (slots dataclass: must be declared).
_load_publish_counter: int = 0
# Consecutive publish_load_stat failures, reset on success. Drives the
# periodic re-warn (see LOAD_PUBLISH_FAIL_WARN_EVERY).
_load_publish_fail_count: int = 0
# Throttle counter for publish_load_stat (slots dataclass: must be declared).
_load_publish_counter: int = dataclasses.field(default=0, init=False, repr=False)
# Consecutive publish_load_stat failures, reset on success. Drives the
# periodic re-warn (see LOAD_PUBLISH_FAIL_WARN_EVERY).
_load_publish_fail_count: int = dataclasses.field(default=0, init=False, repr=False)

@Kangyan-Zhou
Kangyan-Zhou force-pushed the kv-events-loadstat-publish branch from 5364c6e to ad66b59 Compare June 18, 2026 03:20
@Kangyan-Zhou Kangyan-Zhou changed the title [disagg] Publish per-scheduler load on a separate ZMQ topic for KV-aware routers [disagg] Publish per-scheduler load on a dedicated ZMQ publisher for KV-aware routers Jun 18, 2026
@Kangyan-Zhou
Kangyan-Zhou force-pushed the kv-events-loadstat-publish branch from ad66b59 to 577e8c1 Compare June 18, 2026 04:04
@Kangyan-Zhou Kangyan-Zhou changed the title [disagg] Publish per-scheduler load on a dedicated ZMQ publisher for KV-aware routers [scheduler] Publish per-scheduler load on a dedicated socket for load-aware routers Jun 18, 2026
@Kangyan-Zhou
Kangyan-Zhou force-pushed the kv-events-loadstat-publish branch 2 times, most recently from b55af78 to 1b5a329 Compare June 18, 2026 05:42
@Kangyan-Zhou
Kangyan-Zhou force-pushed the kv-events-loadstat-publish branch 2 times, most recently from 10ab0a2 to 24d4665 Compare July 24, 2026 19:01
ShangmingCai added a commit to ShangmingCai/sglang that referenced this pull request Aug 12, 2026
…-aware routers

The cache-aware-zmq router infers per-worker load from a router-side
in-flight counter. Expose the engine's true load so routers can route on
real queue depth.

Load reporting lives in its own module
(managers/scheduler_components/load_publisher.py), independent of KV-cache
events. SchedulerLoadPublisher runs a dedicated ZMQ PUB socket on its own
port range (packed after the KV-event range at kv_base + dp_size),
publishing a periodic LoadStat (num_running_reqs, num_waiting_reqs,
num_tokens, max_total_num_tokens). The load port base is advertised under
/server_info's kv_events block as load_endpoint_port_base.

One publisher per independent KV cache, on a derivable port: gated on
pp/attn-TP/CP rank 0 (matching SchedulerKvEventsPublisher) and keyed by
select_kv_publisher_dp_rank so pure-DP replicas don't collide on one
port. Non-tcp:// KV-event endpoints (ipc://, inproc://) decline load
publishing instead of raising at scheduler startup, and every disabled
path clears `enable` so the (queue-walking) snapshot is never computed
for a null sink.

This is the external counterpart of managers/load_snapshot.py: that path
fans LoadSnapshots into SHM (or zmq PUSH to node 0) for consumers inside
the deployment — DP dispatch and /v1/loads — neither of which an
out-of-process router that only knows the worker URL can subscribe to.
The payload is a compact tagged subset of the snapshot so the
router-facing wire contract stays fixed while the internal snapshot keeps
growing fields.

The snapshot is sourced from the load inquirer (live scheduler counts,
ungated by --enable-metrics). Publishing is throttled and best-effort: a
failure never crashes the scheduler loop and re-warns periodically.

Adds CPU tests pinning the LoadStat msgpack array shape the router
decoder depends on, plus the rank/port gating matrix (pure DP vs DP
attention, PP stages, non-tcp decline, disabled-path enable clearing).

Ported to main from combine/router-admission-http2-loadaware
(1b5a329) and PR sgl-project#28599's follow-up fixes (4dafc5a), where this has
been running in production; adapted to main's get_observability() config
plumbing, LoadSnapshot-returning load inquirer, and no-new-dataclass
convention.

Co-authored-by: Kangyan Zhou <zky314343421@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ShangmingCai added a commit to ShangmingCai/sglang that referenced this pull request Aug 12, 2026
…-aware routers

The cache-aware-zmq router infers per-worker load from a router-side
in-flight counter. Expose the engine's true load so routers can route on
real queue depth.

Load reporting lives in its own module
(managers/scheduler_components/load_publisher.py), independent of KV-cache
events. SchedulerLoadPublisher runs a dedicated ZMQ PUB socket on its own
port range (packed after the KV-event range at kv_base + dp_size),
publishing a periodic LoadStat (num_running_reqs, num_waiting_reqs,
num_tokens, max_total_num_tokens). The load port base is advertised under
/server_info's kv_events block as load_endpoint_port_base.

One publisher per independent KV cache, on a derivable port: gated on
pp/attn-TP/CP rank 0 (matching SchedulerKvEventsPublisher) and keyed by
select_kv_publisher_dp_rank so pure-DP replicas don't collide on one
port. Non-tcp:// KV-event endpoints (ipc://, inproc://) decline load
publishing instead of raising at scheduler startup, and every disabled
path clears `enable` so the (queue-walking) snapshot is never computed
for a null sink.

This is the external counterpart of managers/load_snapshot.py: that path
fans LoadSnapshots into SHM (or zmq PUSH to node 0) for consumers inside
the deployment — DP dispatch and /v1/loads — neither of which an
out-of-process router that only knows the worker URL can subscribe to.
The payload is a compact tagged subset of the snapshot so the
router-facing wire contract stays fixed while the internal snapshot keeps
growing fields.

The snapshot is sourced from the load inquirer (live scheduler counts,
ungated by --enable-metrics). Publishing is throttled and best-effort: a
failure never crashes the scheduler loop and re-warns periodically.

Adds CPU tests pinning the LoadStat msgpack array shape the router
decoder depends on, plus the rank/port gating matrix (pure DP vs DP
attention, PP stages, non-tcp decline, disabled-path enable clearing).

Ported to main from combine/router-admission-http2-loadaware
(1b5a329) and PR sgl-project#28599's follow-up fixes (4dafc5a), where this has
been running in production; adapted to main's get_observability() config
plumbing, LoadSnapshot-returning load inquirer, and no-new-dataclass
convention.

Co-authored-by: Kangyan Zhou <zky314343421@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Kangyan-Zhou
Kangyan-Zhou force-pushed the kv-events-loadstat-publish branch 2 times, most recently from 838b296 to 957e56c Compare August 12, 2026 23:56
…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
@Kangyan-Zhou
Kangyan-Zhou force-pushed the kv-events-loadstat-publish branch from 957e56c to cf60252 Compare August 13, 2026 01:17
ShangmingCai added a commit that referenced this pull request Aug 13, 2026
…rride, sync socket

Convergence pass against Kangyan's refreshed #28599 and its
pr28599-loadstat-original variant, keeping this PR's wire contract and
cadence while adopting the parts of #28599 that are strictly better:

- Endpoint parsing moves to NetworkAddress (proper IPv6): bind-style is
  decided on the parsed host, not a substring match — "::" appears inside
  every IPv6 address, so the substring form would call the concrete
  remote host in tcp://[2001:db8::5]:5557 bindable, advertise it, and
  then fail to bind. Connect-style (concrete-host) KV endpoints now
  decline load publishing on both sides instead of the PUB socket
  connecting to its own advertised range and publishing into a void.

- New --load-publish-endpoint (observability namespace) moves the load
  range outright when the packed default collides with something on the
  host; it must be a bindable wildcard TCP address and must not overlap
  the KV/replay ranges (declined with a logged reason otherwise).
  derive_load_port_base grows into resolve_load_pub_range, which returns
  (host, base) plus a decline reason so the scheduler logs actionable
  misconfigurations once while /server_info stays silent per request.

- The transport is now a plain synchronous PUB socket owned by
  SchedulerLoadPublisher (HWM 8, LINGER 0, NOBLOCK sends): a PUB send is
  an enqueue to ZMQ's IO thread, so the per-scheduler background thread,
  unbounded queue, and replay machinery inherited from ZmqEventPublisher
  are gone. The wire is unchanged — three frames [b"load", BE-i64 seq,
  msgpack LoadStat] — so existing subscribers keep working; the publisher
  now stamps attn_dp_rank itself.

- Publish-failure warnings are wall-clock throttled (first failure, then
  at most once per 60s): a count-based bound re-warns at a rate
  proportional to the scheduler loop, which is the flood it exists to
  stop.

Kept from this PR (and not taken from #28599): the compact LoadStat
array wire contract (decoupled from LoadSnapshot's growing fields and
already decoded by the router side), the auto-skip past an overlapping
replay range (28599 silently disables on the conventional kv/replay
config), changed-gauge-publishes-immediately dedup with a 1s heartbeat
(a pure wall-clock floor delays the busy->idle transition publish), the
shared is_kv_publisher_rank gate, load_topic advertisement, and the
snapshot reuse between the DP-balancing and router-facing sinks.

Tests: gating matrix reworked against the _open_pub_socket seam (real
resolution logic, no TCP ports claimed in CI), bind-style host matrix,
concrete/bare-IPv6 declines, explicit-endpoint move/overlap/bindable
cases, frame-layout pin, and /server_info coverage for the moved and
omitted advertisements.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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