Repository navigation
Conversation
-tp 8 on two 4-GPU nodes: the node that runs TP rank 0 runs the engine, the
API server and ranks 0-3; the other runs only ranks 4-7.
node0: -tp 8 --tensor-parallel-size-local 4 \
--data-parallel-master-ip <node0> --data-parallel-base-port <port>
node1: the same, plus --tensor-parallel-rank 4
The engine reaches the remote ranks through AsyncIOProcManager's existing
channels: the broadcast queue gets remote readers (aiter's MessageQueue
already serves them over ZMQ), and their reply channels listen on TCP. The
manager publishes those endpoints and the runner's constructor arguments in
the model-runner TCPStore CoreManager serves, waits for every remote reader to
subscribe (a PUB socket drops what it sends before that), then releases them.
The remote node spawns AsyncIOProc workers from that and stops them if one
fails or the engine's store goes away.
ModelRunner places each worker on its node's GPU (rank - tp_rank_start) and
takes the minimum KV block count over the TP group. Barrier RPCs are refused:
a multiprocessing barrier does not reach another node. DP, DP attention, PP,
PCP, --fake-eplb and RapidServe are rejected with a cross-node TP group.
dcp_persistent_supported() said gfx950 only. On gfx1250 the PS1 FP8 decode (AITER_MLA_DECODE_PS1_FLYDSL=1) is persistent and returns the LSE that DCP's merge needs (aiter #5844 added DCP to it), so DCP decode can take it. Without it the decode falls to gfx1250's non-persistent ASM kernel, which takes only 64-token pages: -tp 8 -dcp 8 for Kimi-K3 failed its first decode capture with 'mla_decode_gfx1250_dispatch: only support page_size == 64'.
🏷️ CI GuideRuns automatically on every eligible PR before approval:
Heavy model tests:
|
Review: #2647 — "feat(tp): run one tensor-parallel group across nodes"
Line numbers refer to 1. A dead TP rank on another node is invisible to the engine, which then hangsWhere:
What happens: rank 5 on node1 dies, for example from an OOM.
Why: Suggested fix:
2. Engine shutdown never reaches the other node; it keeps its GPUs, and a restarted engine is mistaken for the old oneWhere:
What happens:
Suggested fix:
3. Unauthenticated pickle deserialization over the network (security)Where:
What happens: any host that can reach node0 on the data-plane network can exploit this. Before this PR the reply channels were node-local IPC paths. Now they are TCP sockets bound on a routable address.
Suggested fix:
4. Rank slices are not cross-checked, and subscribers are counted without knowing which rank they areWhere:
What happens:
Suggested fix:
5. Kimi-K3 mono decode is not refused when the TP group spans nodesWhere:
What happens:
Suggested fix: add What happens:
Suggested fix:
|
…TP setup
Every NCCL communicator created on first use takes its unique id from the
CoreManager's TCPStore, and a rank's client of that store kept torch's default
300 s timeout. When the TP group spans nodes, the trays reach such a collective
as far apart as their checkpoint loads differ, which is more than 300 s on a
tray that cannot page-cache the checkpoint.
Kimi-K3, -tp 8 over CT2 (2 TB RAM) + CT17 (1 TB RAM), no DCP: CT17's weight
load drained for 772 s against CT2's 17 s. CT2's ranks 1-3 reached the
lm_head all-gather of the warmup while rank 0 had not, waited 300 s for its
ncclUniqueId ("store->get('0') got error: wait timeout after 300000ms") and
the engine shut down. With DCP the post-load DCP all-gather happens to create
that communicator while all eight ranks are still together, so the same
trays started.
A rank whose TP group spans nodes now creates its store client with
ATOM_MULTINODE_TP_TIMEOUT_S (default 1800 s), the wait the rest of the
cross-node TP setup already uses. Single-node ranks keep the default.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mono builds its peer buffers across the TP group with HIP IPC, which cannot map a GPU on another node, and nothing turned it off for a cross-node TP group: common_refusals checks TP size, EP, DP, PP and context parallelism, and _check_multinode_tp does not refuse mono. The cross-node runs validated so far all used DCP, which mono already refuses; a cross-node TP run without DCP (Kimi-K3 -tp 8 on two 4-GPU nodes) enables it. common_refusals now refuses a TP group that spans nodes, so mono turns itself off with a logged reason instead of failing at IPC handle exchange. A config without the attribute (plugin modes) reads as single-node, like compilation_config. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…5 notes The 20261027 image (sha256:5eefef68; ATOM d8877f1, aiter 7061cbd8) dropped the ATOM #2554 and #2557 overlays that the 20261016 and 20261022 images carried. As shipped it fails in post-load weight processing with `AssertionError: 6284 % 16 == 12`: KDA's fused in_proj (N 6284 per rank) is preshuffled without #2554's padding of N. New section on the image: what changed, the failure, and a workaround that serves /app/ATOM with both overlays. The patch files are under recipes/Kimi-K3-455-TP8-patches/; the block makes 6c960542, the same tree as the validated run. GSM8K on E15 trays 05 + 07, 2026-10-27, K3_MOE=ep, custom all-reduce: strict-match 0.9651 +-0.0051, flexible-extract 0.9636. Before you start: the data-plane NIC where it is not enp1s0f1; the checkpoint revision used (f831ab66); the file-list md5 needs LC_ALL=en_US.UTF-8 sort (C sorts to 2773d607...). Known issues: #6768 is merged, retuned, and in the new image; #2647 is in the new image; rows for the startup failure and the md5 locale. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
LGTM |
|
@gbyu-amd please help resolve the conflict |
|
Re-checked at head The two new commits
Status of the six findingsI re-read each one against
@zejunchen-zejun — does the LGTM cover the PR as a whole, or just the two new commits? Asking because five of the six are still live, including 1-3, which your original review called out as hangs / GPUs left held / a network RCE surface. Happy to be told they are deliberate follow-ups; I just do not want to read the approval as "those are resolved" if they are not. One new issue, from
|
AsyncIOProcManager takes main's on_worker_failure as well as this branch's local_proc_num, and EngineCore passes both. tests/test_distributed_store.py keeps the new tests of both sides. main's test_engine_initializes_tracing_before_ready stubs AsyncIOProcManager and EngineCore's config; its stub now takes local_proc_num and its config sets is_multinode_tp=False.
|
@valar-cop review this pr |
V4 Mono builds its peer buffers with HIP IPC (EpochPeerBuffer), which cannot map a GPU on another node. It admits a deployment through its own deployment_refusal, not common_refusals, so the cross-node refusal there does not reach it, and a pure TP8 group over two 4-GPU nodes passes its TP8 check. Mono is on by default (#2586), so such a run would fail at IPC handle exchange. deployment_refusal now refuses a TP group that spans nodes, with the reason common_refusals gives, so V4 Mono keeps the native path instead.
valar-cop
left a comment
There was a problem hiding this comment.
some changes needed..
gsm8k for cross-node tp8 without dcp?
ci green.
| start = engine_args.tensor_parallel_rank | ||
| local = engine_args.tensor_parallel_size_local | ||
| ip = engine_args.data_parallel_master_ip | ||
| port = engine_args.data_parallel_base_port |
There was a problem hiding this comment.
env var works on node0 but fails on node1? resolve through ParallelConfig so both nodes agree..
|
|
||
| @property | ||
| def is_multinode_tp(self) -> bool: | ||
| return self.tp_size_local < self.tp_world_size or self.tp_rank_start > 0 |
There was a problem hiding this comment.
local 16 with tp 8 is silently ignored here, gate on flags set and reject local > tp?
| """Where `rank` sends its replies: IPC from this node, TCP from another.""" | ||
| if rank < self.local_proc_num: | ||
| return get_open_zmq_ipc_path() | ||
| return f"tcp://{host}:{get_open_port()}" |
There was a problem hiding this comment.
ipv6 breaks this, use get_tcp_uri instead of string format..
| logger = logging.getLogger("atom") | ||
|
|
||
|
|
||
| def managed_store_client_kwargs(config) -> dict: |
There was a problem hiding this comment.
why is this helper here? move it to tp_multinode so tests run without aiter..
| ) | ||
| args = parser.parse_args() | ||
|
|
||
| if args.tensor_parallel_rank > 0: |
There was a problem hiding this comment.
only in api_server? other entrypoints accept the flag and build a partial engine, handle it once in a shared place..
valar-cop
left a comment
There was a problem hiding this comment.
a few small things..
gsm8k for cross-node tp8 without dcp? and a run on current head?
ci green.
|
|
||
| import pytest | ||
|
|
||
| pytest.importorskip("aiter") |
There was a problem hiding this comment.
why importorskip aiter? profile.py only needs dataclasses, so this never runs in ci
| start = engine_args.tensor_parallel_rank | ||
| local = engine_args.tensor_parallel_size_local | ||
| ip = engine_args.data_parallel_master_ip | ||
| port = engine_args.data_parallel_base_port |
There was a problem hiding this comment.
node1 never builds ParallelConfig, so ATOM_DP_BASE_PORT/MASTER_IP are ignored here? resolve the endpoint the same way both nodes do
|
|
||
| @property | ||
| def is_multinode_tp(self) -> bool: | ||
| return self.tp_size_local < self.tp_world_size or self.tp_rank_start > 0 |
There was a problem hiding this comment.
local > tp is accepted and silently ignored here? validate whenever either flag was set
| """Where `rank` sends its replies: IPC from this node, TCP from another.""" | ||
| if rank < self.local_proc_num: | ||
| return get_open_zmq_ipc_path() | ||
| return f"tcp://{host}:{get_open_port()}" |
There was a problem hiding this comment.
no brackets for ipv6, use get_tcp_uri instead of formatting by hand
| logger = logging.getLogger("atom") | ||
|
|
||
|
|
||
| def managed_store_client_kwargs(config) -> dict: |
There was a problem hiding this comment.
model_runner imports aiter so the tests skip in ci, move this to tp_multinode.py?
| ) | ||
| args = parser.parse_args() | ||
|
|
||
| if args.tensor_parallel_rank > 0: |
There was a problem hiding this comment.
atomesh server builds a full local engine with this flag and no error, handle it once where EngineArgs becomes an engine
- A node that runs only TP workers took the engine's address from its CLI flags, while the engine's node takes it through ParallelConfig, which applies ATOM_DP_MASTER_IP and ATOM_DP_BASE_PORT. Set through the environment, node0 started and node1 refused to. Both now resolve it through EngineArgs.parallel_config(). - The TP-slice check ran only once the slice was cross-node, so --tensor-parallel-size-local 16 with -tp 8 was silently ignored. It now runs whenever either flag is set; the cross-node refusals still apply only across nodes. - A remote rank's reply address was formatted by hand, without the brackets an IPv6 host needs. It uses get_tcp_uri. - Only the OpenAI API server turned a node with --tensor-parallel-rank > 0 into a worker-only one; the atomesh server and the examples built a partial engine there. EngineArgs.create_engine now does it for every entrypoint. The API server on such a node now loads its tokenizer first, as it does on a remote DP node. - ATOM_MULTINODE_TP_TIMEOUT_S reached the store client but no process group. The default group and aiter's TP groups are created without a timeout and take torch's defaults, 30 minutes for gloo and 10 for NCCL. A rank whose TP group spans nodes now raises both to it, never lowering them, the way #2817 raises the gloo default when DP spans nodes. - The store client moves to tp_multinode, which imports without aiter, so its tests run in CI; it builds the client itself, and the test asserts what TCPStore receives. The DeepSeek-V4 Mono test loads profile.py from its file for the same reason. - docs: ATOM_MULTINODE_TP_TIMEOUT_S gets a section of its own with its full scope. The section says that AITER_MLA_DECODE_PS1_FLYDSL must match on every node, and what happens when it does not.
valar-cop
left a comment
There was a problem hiding this comment.
some changes needed..
gsm8k for cross-node tp8 without dcp? and a run on current head?
ci green.
| ) | ||
|
|
||
| assert 1 <= self.tensor_parallel_size <= 8 | ||
| self._check_multinode_tp() |
There was a problem hiding this comment.
test bypasses post_init, so reverting this line still passes. pin the call site?
Five findings from this PR's own review at 723081a. The deriver fix landed: 20 noise rules to 0, the diff correctly classified infra-only, so all five came from reading rather than from a rule checklist. **build_review dropped the card's one-sentence summary.** It collected the title, the verdict lines, the findings, the index and the refutation line, and left out the bold sentence saying what the PR does -- the one SKILL.md requires and whose audience it names: "a reviewer who has not opened the diff". The posted review is exactly where that reader is, and every published review so far opened on a verdict with no statement of what was being judged. Pinned by a test that fails when the line is dropped again. **The evidence section cited a symbol that does not exist.** It said ATOM#2707 sets `uses_router_gemm` alongside the weight padding. That symbol is in no ATOM branch a reader can open -- not in #2707's diff, not in main -- and check_citations.py's own comment shipped the same claim as the justification for having a citation checker. So the argument that this bot's citations are sound rested on an unresolvable citation. The anecdote is gone from the comment (the mechanism needs none) and that BLOCK is withdrawn from the description rather than restated. The other BLOCK stands, with its line number corrected: #2647's localhost rejection moved from :110 to :157. **rules.md's header, its bodies and the description told three stories.** The description claimed every rule carries a merged ATOM fix; seven bodies say in their own text that they are carried from aiter with no ATOM instance found, A3 cites an unmerged commit, and the header still said "most do not yet carry one". All three now state the policy the file actually applies: anchored, or labelled as not anchored, with the merged-fix bar applying to anything new. **maintainer-lens.md was not "verbatim, not a word edited".** The 435-line body is byte-identical, but the five-line skill frontmatter was replaced by a provenance header -- necessarily, or the file would declare a second skill with the same name. The one checkable claim in the section addressed to @valarLip failed on its first lines; it now says exactly what changed. **triage.py's N-family comments were numbered P1-P5** while every hit() there emits N1-N6, and rules.md records P1-P5 as a deleted performance family. The same ids meant two things inside one PR. Renumbered; all six now match what the code emits. selftest 102 green / 0 red, 0 drift, 38 pytest, SKILL.md 500/500, MAPPING.md regenerated, ruff --isolated --select F,E9 clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Problem
ATOM's tensor parallelism runs on one node:
-tp 8needs 8 GPUs on the host. On nodes with 4 GPUs (MI455X), Kimi-K3 with TP8 attention needs one TP group over two nodes.On gfx1250, DCP decode also could not run persistent. The non-persistent ASM decode it fell back to takes only 64-token pages, so
-tp 8 -dcp 8for Kimi-K3 failed its first decode graph capture withmla_decode_gfx1250_dispatch: only support page_size == 64.Changes
[TP] Run one tensor-parallel group across nodes. New flags
--tensor-parallel-size-local(ranks on this node) and--tensor-parallel-rank(first rank on this node).atom/model_engine/tp_multinode.py).AsyncIOProcManager's existing channels: the broadcast queue gets remote readers (aiter'sMessageQueuealready serves them over ZMQ), and their reply channels listen on TCP.CoreManagerserves, waits for every remote reader to subscribe (a PUB socket drops what it sends before that), then releases them. The remote node spawns its workers from that, and stops them if one fails or the engine's store goes away.ModelRunnerplaces each worker on its node's GPU (rank - tp_rank_start) and takes the minimum KV block count over the TP group.--fake-eplband RapidServe are rejected with a cross-node TP group.ATOM_MULTINODE_TP_TIMEOUT_S(default 1800 s): how long the nodes wait for each other at startup.[MLA][DCP] Run DCP decode persistent on gfx1250 with the PS1 kernel.
dcp_persistent_supported()is now true on gfx1250 whenAITER_MLA_DECODE_PS1_FLYDSL=1selects the PS1 FP8 decode. That kernel is persistent and returns the LSE that DCP's merge needs (ROCm/aiter#5844 added DCP to it).Usage
Validation
Kimi-K3 on two MI455X nodes (4 GPUs each): TP8 + DCP8 attention, MoE TP8 with ROCm/aiter#6706, RCCL between the nodes. GSM8K 5-shot:
NCCL_MNNVL_ENABLE=1)NCCL_MNNVL_ENABLE=0)--enforce-eagerhipErrorInvalidValue, so that row runs eager.ATOM_MONO_ENABLE=1); it has not been tried with a cross-node TP group.blackis clean.ruffreports nothing new: its three findings inengine_core_mgr.pyare on main too. The non-GPU unit tests have not been run locally.Notes