fix(distributed): give registry node accessors a defined order - #1284
Merged
Conversation
`RegistryInner.nodes` is a `HashMap`, and `all_nodes` and `nodes_with_role` returned its `.values()` unsorted while their consumers depend on position. `RandomState` seeds every map instance separately rather than once per process, so the order varied between registries built from the same config, and node selection was not reproducible from identical cluster state. `all_nodes` feeds `get_load_infos_for_roles` and thence `select_prefill_node` / `select_decode_node` on the primary request path, where `RoundRobin` indexes positionally, `LeastLoaded` uses `min_by_key` (first minimum wins) and `MemoryAware` uses `max_by_key` (last maximum wins). An idle cluster ties on every load metric, and the shipped defaults are `LeastLoaded` for prefill and `MemoryAware` for decode, so on a cold cluster the hash seed alone decided which node served a request. `nodes_with_role` feeds the failover candidate lists, which the re-routing loop indexes with round-robin counters. Both accessors now sort by `config.id`, which is what the sibling accessors `nodes_at_stage` and `nodes_at_rank` already did for the same reason. `sort_by` rather than `sort_by_key`: a `String` key is re-evaluated on every comparison, measured at 78 clones for 12 elements against 0 allocations for `sort_by`. Two further accessors carried the same exposure and are sorted too. `peer_addresses` is public and `Vec`-shaped, and its one consumer is order-insensitive only by accident. `topology_summary` prints its node list to operators, so two snapshots of one unchanged cluster did not diff cleanly. `handle_node_failure` needed a second change for the failover case to actually become reproducible: the affected request ids are collected from a `HashMap` as well, and the loop hands candidates out round-robin in exactly that sequence, so sorting only the candidate list would have left the request-to-node pairing arbitrary. The regression tests build 64 fresh registries and 32 fresh routers per case, because repeated calls against one instance cannot tell a defined order apart from a lucky one, and they assert explicit expected sequences rather than mere self-agreement. All nine fail against the unfixed code, most within the first two iterations. Closes #1277
Member
Author
|
That is +9 against
|
Bilingual report for the distributed registry ordering fix, recorded before the merge because this repository carries the `TECHNICAL_REPORTS/.keep-reports` marker. Force-added since `TECHNICAL_REPORTS/` is gitignored. Refs #1277
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.
Summary
RegistryInner.nodesis aHashMap, and two of its list accessors handed back.values()unsorted while their consumers select by position or tie-break on input order.RandomStateseeds every map instance separately rather than once per process, so identical cluster state produced different node selections between runs. Both accessors now sort byconfig.id, which is what the sibling accessorsnodes_at_stageandnodes_at_rankalready did.What changed
src/distributed/registry.rs:all_nodesandnodes_with_rolesort the returnedVecbyconfig.id. Signatures and return types are unchanged; only the order becomes defined.src/distributed/registry.rs:peer_addressesandtopology_summaryget the same treatment. See the scope note below.src/distributed/disaggregated/request_router.rs:handle_node_failuresorts the affected request ids before the round-robin re-routing loop consumes them.src/distributed/registry_tests.rs: five ordering tests over 64 independently constructed registries each.src/distributed/disaggregated/request_router_tests.rs: four selection-determinism tests over 32 independently constructed routers each, driving the real routing entry points rather than the accessor.Design choices
Sort in the accessor, not at the call sites. There are seven consumers across three modules and the set grows; a per-call-site fix means every future caller has to rediscover the hazard. The sort runs on a local
Vecafter the read lock has already been taken for the clone, so it holds no lock longer than before, and node counts are cluster-sized next to the existing clone of everyRegisteredNode.sort_by, notsort_by_key. The issue recommends this and the recommendation holds.sort_by_keyre-evaluates the key on every comparison, so aStringkey allocates per comparison rather than per element. Measured on a 12-element scrambledVec<String>:sort_by_key(|s| s.clone())performed 78 key evaluations, each a clone, whilesort_by(|a, b| a.cmp(b))performed 39 comparisons and allocated nothing.sort_by_keyalso cannot borrow the key out of the element, so a clone is the only way to spell it.BTreeMapstays rejected. Per the issue: it would penalize the O(1)get_node/set_node_status/local_pp_tp_coordslookups for a property only the list accessors need. Nothing found during implementation changes that.Scope: which accessors actually carry the exposure
The issue describes
nodesas having four list accessors and puts two of them in scope. Swept the file and the module; the corrected picture:all_nodesnodes_with_rolenodes_at_stage/nodes_at_rankpeer_addressesheartbeat.rs:214) broadcasts to every peer, so insensitive todaytopology_summarydiscovery.rs:96andcluster_init.rs:253find_pp_tp_node.values().find(..)ClusterConfig::validaterejects duplicate(stage, rank)pairs (config.rs:465)peer_addressesandtopology_summaryare beyond the issue's stated in-scope list. Including them is deliberate: they are the same defect in the same file, the fix is two lines each, and leaving them means the next caller ofpeer_addressesinherits a hazard the sibling accessors no longer have.topology_summaryis also the one place where an operator can see the disorder directly, since re-running it on an unchanged cluster reshuffles the node list.The order-insensitive consumers the issue cleared are confirmed:
discovery.rs:50(probes run concurrently),heartbeat.rs:131(per-node registration),router_front.rs:409(per-node health probe).router_front.rs:561(/stats) is confirmed cosmetic and now stable as a side effect. One consumer the issue does not mention,router_front.rs:1218, takes only.len()ofnodes_with_roleand is insensitive.A gap in the issue's own instance 1
Sorting
nodes_with_rolealone does not make failover re-routing reproducible.handle_node_failurecollects the affected request ids fromself.requests, which is also aHashMap, and the loop assigns candidates round-robin in exactly that sequence. With sorted candidates but unsorted request ids, the set of targets is stable while the request-to-node pairing still moves between runs, which is the part an operator is trying to reproduce.affected.sort()closes it. The newfailover_reroute_assignment_is_stable_across_routerstest asserts the full pairing, so it covers both halves.Acceptance criteria
Verified by test:
nodes_with_rolereturns nodes ordered byconfig.idascending, for every role. Covered for Prefill, Decode, and Hybrid, plus the empty case for a role with no members.all_nodesreturns nodes ordered byconfig.idascending.nodes_with_role_filtertest still passes unchanged. It is untouched, and it structurally cannot observe this bug: it indexes a single-element list.select_prefill_nodeandselect_decode_node, at unit level, underRoundRobinand under theLeastLoaded/MemoryAwaretie cases. The four router tests callroute_to_prefill/route_to_decode/handle_node_failureon freshly built routers, so they exercise the real path from registry throughget_load_infos_for_rolesinto the strategy arm.make verify-fmtpasses. It is exactlycargo fmt --all -- --check, run here at exit 0.Verified by construction, not by observation:
select_prefill_node/select_decode_node, and the unit tests confirm it for the tie cases, but a live cluster adds real metrics that break most ties before the order matters.Not verified by observation:
make verify-clippyas written runs--features metal,accelerateand cannot execute on this Linux/CUDA box. The CUDA lint path was run instead; see the test plan.make verify-test-cudamerge gate is not run in this PR's verification and is left to the reviewer.Test plan
Commands and their real exit codes, each captured directly rather than read off a pipeline:
cargo test --profile test-fast --features cuda --lib --no-runthen the test binary with narrow filters (one filter per invocation), all at exit 0:distributed::registry18 passed,distributed::disaggregated::request_router29 passed,distributed::scheduler16 passed,distributed::routing19 passed,distributed::heartbeat9 passed,distributed::discovery3 passed,distributed::cluster_init14 passed,server::router_front20 passed. 0 failed throughout.cargo fmt --all -- --check, exit 0.The nine new tests were run against the unfixed accessors first and all nine failed, most on iteration 0 or 1. For example
all_nodesreturned["yankee-prefill", "alpha-decode", "mike-prefill", "bravo-decode", "zulu-hybrid"]on the first registry, andround_robin_selection_sequence_is_stable_across_routersproduced["prefill-0", "prefill-1", "prefill-0", "prefill-1"]on one router and["prefill-1", "prefill-0", "prefill-1", "prefill-0"]on the next.cargo clippy --lib --tests --features cuda -- -D warnings -A clippy::err_expect, exit 0. Nothing this PR touches produces a lint.cargo clippy --lib --tests --features cuda -- -D warnings, exit 101, on one pre-existing error:clippy::err_expectatsrc/multimodal/host_preprocessor_tests.rs:416, a file this PR does not touch. The repo'sverify-clippylints--features metal,accelerate, so the CUDA lint path is ungated and this error predates the branch. It is the only error in the run and none of the four changed files appear in the output.Follow-ups found while sweeping, not fixed here
The same defect class appears three more times in
src/distributed/, all outside this issue's subsystem and all with the identical shape of aHashMapiteration collected into aVec, handed to a stablesort_by_key, then consumed as a prefix:src/distributed/tensor_parallel/cache_manager.rs:711:check_pressurestops walking the candidate list once the memory target is met, so among allocations tied oncurrent_offsetorlast_accessedthe hash order decides which sequencesapply_evictiondestroys.src/distributed/pipeline/cache_manager.rs:697: same stable-sort-over-hash-order defect, published asPreemptionSignal.sequence_ids, a field documented as eviction priority order. No in-repo consumer takes a prefix yet.src/distributed/request_tracker.rs:333: sorts terminal requests bycreated_atand takes a prefix, so same-Instantties are broken by hash order.These change eviction behavior rather than routing, so they deserve their own issue and their own tests rather than a silent ride along with this one.
Closes #1277