[scheduler] Publish per-scheduler load on a dedicated socket for load-aware routers - #28599
Kangyan-Zhou wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
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.
| # 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 |
There was a problem hiding this comment.
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).
| # 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) |
5364c6e to
ad66b59
Compare
ad66b59 to
577e8c1
Compare
b55af78 to
1b5a329
Compare
10ab0a2 to
24d4665
Compare
…-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>
…-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>
838b296 to
957e56c
Compare
…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
957e56c to
cf60252
Compare
…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>
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 aLoadSnapshotevery publish cycle, which today feeds/v1/loadsand 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.
PubLoadSnapshotWriterjoinsShmLoadSnapshotWriterandZmqLoadSnapshotWriterinmanagers/load_snapshot.py, behind the samewrite()/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.
SchedulerLoadInquirernow holds the writers and gainspublish();Scheduler.publish_load_snapshotis deleted and its two call sites delegate. The snapshot is built once per cycle no matter how many writers are due — relevant becauseget_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 rankrusesbase + r). Both the writer and/server_info'sload_endpoint_port_baseroute 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:
null;tcp://—ipc://andinproc://serve KV events fine but have no port to offset;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_CONFLATEwould 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/loadsomits 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-idleis off by default, so an unthrottled warning would turn a permanently broken writer into thousands of lines per second.Notes for reviewers
Scheduler.publish_load_snapshotwas public; forks overriding it will need to move toSchedulerLoadInquirer.publish.--kv-events-confignow reserves2 * dp_sizeconsecutive ports from the configured base rather thandp_size. Co-located engines spaceddp_sizeapart will collide. The argument documentation is not yet updated for this.--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:publish()— single collection per cycle, independent per-writer intervals,forcesemantics, per-writer fault isolation, and the failure throttle;array_like=TrueonLoadSnapshotor renaming a field fails here rather than silently breaking consumers.CI States
Latest PR Test (Base): ❌ Run #31657253640
Latest PR Test (Extra): ❌ Run #31657253406