Repository navigation
feat(tp): run one tensor-parallel group across nodes #2647
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
ce21227
c59ffd5
c3cc236
24049fe
7742448
2b10dd6
3095411
52babe1
b3bfc47
58c16c1
26f6251
7c4e4c2
a011016
1b1906b
c6b7ea3
6f91e7b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1069,6 +1069,14 @@ class ParallelConfig: | |
|
|
||
| data_parallel_master_ip: str = "127.0.0.1" | ||
|
|
||
| tensor_parallel_size_local: int | None = None | ||
| """TP ranks (model-runner workers) this node runs. None means all of them, | ||
| the single-node case. Set it below tensor_parallel_size to spread one TP | ||
| group over several nodes; see atom/model_engine/tp_multinode.py.""" | ||
| tensor_parallel_rank: int = 0 | ||
| """First TP rank this node runs. The node that runs rank 0 also runs the | ||
| engine and the API server; the others run only their workers.""" | ||
|
|
||
| @property | ||
| def is_multinode_dp(self) -> bool: | ||
| """Whether this node owns only part of the global DP group. | ||
|
|
@@ -2207,6 +2215,57 @@ def tp_world_size(self) -> int: | |
| visible = torch.cuda.device_count() | ||
| return visible if 0 < visible < tp else tp | ||
|
|
||
| @property | ||
| def tp_size_local(self) -> int: | ||
| """TP worker processes on this node: fewer than `tp_world_size` when | ||
| one TP group spans several nodes.""" | ||
| local = self.parallel_config.tensor_parallel_size_local | ||
| return self.tp_world_size if local is None else local | ||
|
|
||
| @property | ||
| def tp_rank_start(self) -> int: | ||
| """First TP rank on this node; it runs `tp_size_local` ranks from here.""" | ||
| return self.parallel_config.tensor_parallel_rank | ||
|
|
||
| @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. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. local > tp is accepted and silently ignored here? validate whenever either flag was set
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 3095411, same change as the earlier thread: the slice is validated whenever either flag is set, and local > tp is rejected. |
||
|
|
||
| def _check_multinode_tp(self) -> None: | ||
| pc = self.parallel_config | ||
| local, start, tp = self.tp_size_local, self.tp_rank_start, self.tp_world_size | ||
| if local < 1 or tp % local or start % local or not 0 <= start < tp: | ||
| raise ValueError( | ||
| f"--tensor-parallel-size-local {local} and --tensor-parallel-rank " | ||
| f"{start} must cut -tp {tp} into whole, equal per-node slices" | ||
| ) | ||
| unsupported = [ | ||
| name | ||
| for name, used in ( | ||
| ("data parallelism", pc.data_parallel_size > 1), | ||
| ("--enable-dp-attention", self.enable_dp_attention), | ||
| ("pipeline parallelism", self.pipeline_parallel_size > 1), | ||
| ("prefill context parallelism", self.prefill_context_parallel_size > 1), | ||
| ("--fake-eplb", self.fake_eplb), | ||
| ("RapidServe", self.enable_rapidserve), | ||
| ) | ||
| if used | ||
| ] | ||
| if unsupported: | ||
| raise ValueError( | ||
| "tensor parallelism across nodes does not support " | ||
| + ", ".join(unsupported) | ||
| ) | ||
| if pc.data_parallel_base_port == 0 or pc.data_parallel_master_ip in ( | ||
| "127.0.0.1", | ||
| "localhost", | ||
| ): | ||
| raise ValueError( | ||
| "tensor parallelism across nodes needs --data-parallel-master-ip " | ||
| "set to the address of the node that runs TP rank 0, and the " | ||
| "same nonzero --data-parallel-base-port on every node" | ||
| ) | ||
|
|
||
| @property | ||
| def capture_sizes(self) -> list[int]: | ||
| """The declared CUDAGraph capture ladder, in batch sizes. | ||
|
|
@@ -2380,6 +2439,8 @@ def __post_init__(self): | |
| ) | ||
|
|
||
| assert 1 <= self.tensor_parallel_size <= 8 | ||
| if self.is_multinode_tp: | ||
| self._check_multinode_tp() | ||
| if self.decode_context_parallel_size > 1: | ||
| assert self.tensor_parallel_size % self.decode_context_parallel_size == 0, ( | ||
| f"tp_size ({self.tensor_parallel_size}) must be divisible by " | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3285,6 +3285,13 @@ def main(): | |
| ) | ||
| args = parser.parse_args() | ||
|
|
||
| if args.tensor_parallel_rank > 0: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. only in api_server? other entrypoints accept the flag and build a partial engine, handle it once in a shared place..
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 3095411: There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. atomesh server builds a full local engine with this flag and no error, handle it once where EngineArgs becomes an engine
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 3095411: the check moved into |
||
| # The node that runs TP rank 0 serves the API; this one only runs the | ||
| # workers of its own ranks. | ||
| from atom.model_engine.tp_multinode import serve_remote_tp_ranks | ||
|
|
||
| raise SystemExit(serve_remote_tp_ranks(EngineArgs.from_cli_args(args))) | ||
|
|
||
| if args.request_log: | ||
| _request_logger = logging.getLogger("atom.request_log") | ||
| _request_logger.setLevel(logging.INFO) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -46,6 +46,7 @@ | |
| from atom.utils import ( | ||
| envs, | ||
| get_mp_context, | ||
| get_open_port, | ||
| get_open_zmq_ipc_path, | ||
| init_exit_handler, | ||
| make_zmq_socket, | ||
|
|
@@ -155,7 +156,7 @@ def __init__( | |
| dp_local_rank = cfg.parallel_config.data_parallel_rank_local | ||
| if dp_local_rank is None: | ||
| dp_local_rank = cfg.parallel_config.data_parallel_rank | ||
| gpu = dp_local_rank * cfg.tp_world_size + rank | ||
| gpu = dp_local_rank * cfg.tp_world_size + rank - cfg.tp_rank_start | ||
| numa_bind_to_node(gpu, label) | ||
| except Exception as e: # noqa: BLE001 - binding is an optimization | ||
| # NUMA binding only affects locality, never correctness, so any | ||
|
|
@@ -178,6 +179,10 @@ def __init__( | |
| self.rpc_queue: queue.Queue | None = None | ||
|
|
||
| self.rpc_broadcast_mq = MessageQueue.create_from_handle(input_shm_handle, rank) | ||
| if self.rpc_broadcast_mq._is_remote_reader: | ||
| from atom.model_engine.tp_multinode import await_release | ||
|
|
||
| await_release(self.rpc_broadcast_mq) | ||
| import atexit | ||
|
|
||
| atexit.register(self._cleanup_shared_memory) | ||
|
|
@@ -464,11 +469,22 @@ class AsyncIOProcManager: | |
| ) | ||
|
|
||
| def __init__( | ||
| self, finalizer, proc_num: int, runner: str, *args, on_worker_failure=None | ||
| self, | ||
| finalizer, | ||
| proc_num: int, | ||
| runner: str, | ||
| *args, | ||
| local_proc_num: int | None = None, | ||
| on_worker_failure=None, | ||
| ): | ||
| self.parent_finalizer = finalizer | ||
| self.on_worker_failure = on_worker_failure | ||
| self.proc_num = proc_num | ||
| # Ranks from local_proc_num on run on other nodes (tp_multinode.py) and | ||
| # reach this node over TCP. | ||
| self.local_proc_num = proc_num if local_proc_num is None else local_proc_num | ||
| n_remote = proc_num - self.local_proc_num | ||
| host = args[0].parallel_config.data_parallel_master_ip if n_remote else None | ||
|
|
||
| io_addrs = [get_open_zmq_ipc_path(), get_open_zmq_ipc_path()] | ||
| self.procs: list[multiprocessing.Process] = [] | ||
|
|
@@ -477,7 +493,10 @@ def __init__( | |
| self.label = f"AsyncIOProcManager({self.runner_label})" | ||
|
|
||
| self.rpc_broadcast_mq = MessageQueue( | ||
| proc_num, proc_num, max_chunk_bytes=16 * 1024 * 1024 | ||
| proc_num, | ||
| self.local_proc_num, | ||
| max_chunk_bytes=16 * 1024 * 1024, | ||
| connect_ip=host, | ||
| ) | ||
| self._block_table_encoder = BlockTableDeltaEncoder() | ||
| scheduler_output_handle = self.rpc_broadcast_mq.export_handle() | ||
|
|
@@ -486,20 +505,22 @@ def __init__( | |
| import atexit | ||
|
|
||
| atexit.register(self._cleanup_shared_memory) | ||
| self.all_ranks_barrier = ctx.Barrier(proc_num) | ||
| # A multiprocessing barrier cannot reach another node; barrier RPCs are | ||
| # refused instead (see _collective_rpc). | ||
| self.all_ranks_barrier = None if n_remote else ctx.Barrier(proc_num) | ||
| init_exit_handler(self) | ||
|
|
||
| # KV output aggregation infrastructure | ||
| self.kv_output_aggregator: KVOutputAggregator | None = None | ||
| self.kv_output_addrs = [get_open_zmq_ipc_path() for _ in range(proc_num)] | ||
| self.kv_output_addrs = [self._reply_addr(i, host) for i in range(proc_num)] | ||
| self.kv_outputs_queues: list[queue.Queue] = [ | ||
| queue.Queue() for _ in range(proc_num) | ||
| ] | ||
| self._pending_kv_aggregation: _PendingKvAggregation | None = None | ||
| self.kv_output_threads: list[threading.Thread] = [] | ||
|
|
||
| # Generic collective-RPC reply channels, one per rank | ||
| self.rpc_output_addrs = [get_open_zmq_ipc_path() for _ in range(proc_num)] | ||
| self.rpc_output_addrs = [self._reply_addr(i, host) for i in range(proc_num)] | ||
| self.rpc_outputs_queues: list[queue.Queue] = [ | ||
| queue.Queue() for _ in range(proc_num) | ||
| ] | ||
|
|
@@ -509,7 +530,7 @@ def __init__( | |
| # rank -> exit code, written by the monitor before it tears down. | ||
| self._dead_ranks: dict[int, int | None] = {} | ||
|
|
||
| for i in range(proc_num): | ||
| for i in range(self.local_proc_num): | ||
| label = f"ModelRunner{i}/{proc_num}" | ||
| # Only rank 0 gets the primary output address | ||
| addrs = [None, io_addrs[1]] if i == 0 else [None, None] | ||
|
|
@@ -569,6 +590,32 @@ def __init__( | |
| self.rpc_output_threads.append(t) | ||
|
|
||
| self.monitor_procs() | ||
| if n_remote: | ||
| from atom.model_engine.tp_multinode import ( | ||
| publish_bootstrap, | ||
| release_remote_readers, | ||
| ) | ||
|
|
||
| publish_bootstrap( | ||
| args[0], | ||
| { | ||
| "proc_num": proc_num, | ||
| "handle": scheduler_output_handle, | ||
| "runner": runner, | ||
| "args": args, | ||
| "kv_output_addrs": self.kv_output_addrs, | ||
| "rpc_output_addrs": self.rpc_output_addrs, | ||
| }, | ||
| ) | ||
| release_remote_readers( | ||
| self.rpc_broadcast_mq, n_remote, lambda: self.still_running | ||
| ) | ||
|
|
||
| def _reply_addr(self, rank: int, host: str | None) -> str: | ||
| """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. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ipv6 breaks this, use get_tcp_uri instead of string format..
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 3095411: There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. no brackets for ipv6, use get_tcp_uri instead of formatting by hand
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 3095411: |
||
|
|
||
| def exit(self): | ||
| if not self.still_running: | ||
|
|
@@ -756,6 +803,18 @@ def _collective_rpc( | |
| for rank in range(self.proc_num) | ||
| ] | ||
|
|
||
| if payload.barrier and getattr(self, "local_proc_num", self.proc_num) < ( | ||
| self.proc_num | ||
| ): | ||
| error = ( | ||
| f"cannot run barrier RPC {func_name!r}: the TP ranks on other " | ||
| f"nodes share no barrier with this one" | ||
| ) | ||
| return [ | ||
| RpcResult(payload.request_id, rank, error=error) | ||
| for rank in range(self.proc_num) | ||
| ] | ||
|
|
||
| logger.debug( | ||
| f"{self.label}: collective_rpc {func_name} id={payload.request_id}" | ||
| ) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
local 16 with tp 8 is silently ignored here, gate on flags set and reject local > tp?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 3095411:
_check_multinode_tpruns whenever either flag is set and rejects a slice that does not cut-tpinto whole, equal pieces, so local 16 with tp 8 now fails at startup.test_tp_slices_are_checked_once_either_flag_is_setcovers it.