674 lines
22 KiB
Python
674 lines
22 KiB
Python
# Licensed to the Apache Software Foundation (ASF) under one
|
|
# or more contributor license agreements. See the NOTICE file
|
|
# distributed with this work for additional information
|
|
# regarding copyright ownership. The ASF licenses this file
|
|
# to you under the Apache License, Version 2.0 (the
|
|
# "License"); you may not use this file except in compliance
|
|
# with the License. You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing,
|
|
# software distributed under the License is distributed on an
|
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
# KIND, either express or implied. See the License for the
|
|
# specific language governing permissions and limitations
|
|
# under the License.
|
|
# ruff: noqa: F401
|
|
"""This module defines a Session in Disco. Session is the primary interface that users interact
|
|
with the distributed runtime.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import pickle
|
|
from collections.abc import Callable, Sequence
|
|
from typing import Any, Optional, Union
|
|
|
|
import numpy as np
|
|
from tvm_ffi import Object, Shape, get_global_func, register_global_func, register_object
|
|
|
|
from .._tensor import Tensor
|
|
from .._tensor import tensor as _as_Tensor
|
|
from ..device import Device
|
|
from . import _ffi_api, process_pool # pylint: disable=unused-import
|
|
|
|
|
|
@register_object("runtime.disco.DRef")
|
|
class DRef(Object):
|
|
"""An object that exists on all workers. The controller process assigns a unique "register id"
|
|
to each object, and the worker process uses this id to refer to the object residing on itself.
|
|
"""
|
|
|
|
def debug_get_from_remote(self, worker_id: int) -> Any:
|
|
"""Get the value of a DRef from a remote worker. It is only used for debugging purposes.
|
|
|
|
Parameters
|
|
----------
|
|
worker_id : int
|
|
The id of the worker to be fetched from.
|
|
|
|
Returns
|
|
-------
|
|
value : object
|
|
The value of the register.
|
|
"""
|
|
return _ffi_api.DRefDebugGetFromRemote(self, worker_id) # type: ignore # pylint: disable=no-member
|
|
|
|
def debug_copy_from(
|
|
self,
|
|
worker_id: int,
|
|
value: np.ndarray | Tensor,
|
|
) -> None:
|
|
"""Copy an Tensor value to remote for debugging purposes.
|
|
|
|
Parameters
|
|
----------
|
|
worker_id : int
|
|
The id of the worker to be copied to.
|
|
|
|
value : Union[numpy.ndarray, Tensor]
|
|
The value to be copied.
|
|
"""
|
|
if not isinstance(value, Tensor):
|
|
value = _as_Tensor(value)
|
|
return _ffi_api.DRefDebugCopyFrom(self, worker_id, value) # type: ignore # pylint: disable=no-member
|
|
|
|
|
|
class DPackedFunc(DRef):
|
|
"""A PackedFunc in a Disco session."""
|
|
|
|
# tvm_ffi Object subclasses cannot store Python attributes by default
|
|
# (the metaclass sets `__slots__ = ()`); list the field(s) we store here.
|
|
__slots__ = ("session",)
|
|
|
|
def __init__(self, dref: DRef, session: "Session") -> None:
|
|
self.__move_handle_from__(dref)
|
|
self.session = session
|
|
|
|
def __call__(self, *args) -> DRef:
|
|
return self.session.call_packed(self, *args)
|
|
|
|
|
|
class DModule(DRef):
|
|
"""A Module in a Disco session."""
|
|
|
|
# tvm_ffi Object subclasses cannot store Python attributes by default
|
|
# (the metaclass sets `__slots__ = ()`); list the field(s) we store here.
|
|
__slots__ = ("session",)
|
|
|
|
def __init__(self, dref: DRef, session: "Session") -> None:
|
|
self.__move_handle_from__(dref)
|
|
self.session = session
|
|
|
|
def __getitem__(self, name: str) -> DPackedFunc:
|
|
func = self.session._get_cached_method("ffi.ModuleGetFunction")
|
|
return DPackedFunc(func(self, name, False), self.session)
|
|
|
|
|
|
@register_object("runtime.disco.Session")
|
|
class Session(Object):
|
|
"""A Disco interactive session. It allows users to interact with the Disco command queue with
|
|
various PackedFunc calling convention."""
|
|
|
|
# tvm_ffi Object subclasses cannot store Python attributes by default
|
|
# (the metaclass sets `__slots__ = ()`); list the fields we store here:
|
|
# the method-lookup cache and the lazily bound import helper.
|
|
__slots__ = ("_cache", "_import_python_module")
|
|
|
|
def _get_cached_method(self, name: str) -> Callable:
|
|
if not hasattr(self, "_cache"):
|
|
cache = self._cache = {} # pylint: disable=attribute-defined-outside-init
|
|
else:
|
|
cache = self._cache
|
|
if name not in cache:
|
|
func = cache[name] = self.get_global_func(name)
|
|
else:
|
|
func = cache[name]
|
|
return func
|
|
|
|
def empty(
|
|
self,
|
|
shape: Sequence[int],
|
|
dtype: str,
|
|
device: Device | None = None,
|
|
worker0_only: bool = False,
|
|
in_group: bool = True,
|
|
) -> DRef:
|
|
"""Create an empty Tensor on all workers and attach them to a DRef.
|
|
|
|
Parameters
|
|
----------
|
|
shape : tuple of int
|
|
The shape of the Tensor.
|
|
|
|
dtype : str
|
|
The data type of the Tensor.
|
|
|
|
device : Optional[Device] = None
|
|
The device of the Tensor.
|
|
|
|
worker0_only: bool
|
|
If False (default), allocate an array on each worker. If
|
|
True, only allocate an array on worker0.
|
|
|
|
in_group: bool
|
|
Take effective when `worker0_only` is True. If True (default),
|
|
allocate an array on each first worker in each group. If
|
|
False, only allocate an array on worker0 globally.
|
|
|
|
Returns
|
|
-------
|
|
array : DRef
|
|
The created Tensor.
|
|
|
|
"""
|
|
func = self._get_cached_method("runtime.disco.empty")
|
|
return func(Shape(shape), dtype, device, worker0_only, in_group)
|
|
|
|
def shutdown(self):
|
|
"""Shut down the Disco session"""
|
|
_ffi_api.SessionShutdown(self) # type: ignore # pylint: disable=no-member
|
|
|
|
@property
|
|
def num_workers(self) -> int:
|
|
"""Return the number of workers in the session"""
|
|
return _ffi_api.SessionGetNumWorkers(self) # type: ignore # pylint: disable=no-member
|
|
|
|
def get_global_func(self, name: str) -> DRef:
|
|
"""Get a global function on workers.
|
|
|
|
Parameters
|
|
----------
|
|
name : str
|
|
The name of the global function.
|
|
|
|
Returns
|
|
-------
|
|
func : DRef
|
|
The global packed function
|
|
"""
|
|
return DPackedFunc(_ffi_api.SessionGetGlobalFunc(self, name), self) # type: ignore # pylint: disable=no-member
|
|
|
|
def import_python_module(self, module_name: str) -> None:
|
|
"""Import a python module in each worker
|
|
|
|
This may be required before call
|
|
|
|
Parameters
|
|
----------
|
|
module_name: str
|
|
|
|
The python module name, as it would be used in a python
|
|
`import` statement.
|
|
"""
|
|
if not hasattr(self, "_import_python_module"):
|
|
self._import_python_module = self.get_global_func("runtime.disco._import_python_module")
|
|
|
|
self._import_python_module(module_name)
|
|
|
|
def call_packed(self, func: DRef, *args) -> DRef:
|
|
"""Call a PackedFunc on workers providing variadic arguments.
|
|
|
|
Parameters
|
|
----------
|
|
func : PackedFunc
|
|
The function to be called.
|
|
*args : various types
|
|
In the variadic arguments, the supported types include:
|
|
- integers and floating point numbers;
|
|
- DLDataType;
|
|
- DLDevice;
|
|
- str (std::string in C++);
|
|
- DRef.
|
|
|
|
Returns
|
|
-------
|
|
return_value : various types
|
|
The return value of the function call.
|
|
|
|
Notes
|
|
-----
|
|
Examples of unsupported types:
|
|
- Tensor, DLTensor,;
|
|
- TVM Objects, including PackedFunc, Module and String.
|
|
"""
|
|
return _ffi_api.SessionCallPacked(self, 0, 0, func, *args) # type: ignore # pylint: disable=no-member
|
|
|
|
def _sync_worker(self, worker_id: int) -> None:
|
|
"""Synchronize the controller with a worker, and it will wait until the worker finishes
|
|
executing all the existing instructions. This function is usually used for worker-0, because
|
|
it is the only worker that is assumed to collocate with the controller. Syncing with other
|
|
workers may not be supported and should only be used for debugging purposes.
|
|
|
|
Parameters
|
|
----------
|
|
worker_id : int
|
|
The id of the worker to be synced with.
|
|
"""
|
|
return _ffi_api.SessionSyncWorker(self, worker_id) # type: ignore # pylint: disable=no-member
|
|
|
|
def _sync_all(self) -> None:
|
|
"""Synchronize the controller with all workers in the current session, and it will
|
|
wait until all workers finish executing all the existing instructions."""
|
|
for i in range(self.num_workers):
|
|
self._sync_worker(i)
|
|
|
|
def sync_worker_0(self) -> None:
|
|
"""Synchronize the controller with worker-0, and it will wait until the worker-0 finishes
|
|
executing all the existing instructions."""
|
|
return self._sync_worker(0)
|
|
|
|
def copy_from_worker_0(self, host_array: Tensor, remote_array: DRef) -> None:
|
|
"""Copy an Tensor from worker-0 to the controller-side Tensor.
|
|
|
|
Parameters
|
|
----------
|
|
host_array : numpy.ndarray
|
|
The array to be copied to worker-0.
|
|
|
|
remote_array : Tensor
|
|
The Tensor on worker-0.
|
|
"""
|
|
return _ffi_api.SessionCopyFromWorker0(self, host_array, remote_array) # type: ignore # pylint: disable=no-member
|
|
|
|
def copy_to_worker_0(self, host_array: Tensor, remote_array: DRef | None = None) -> DRef:
|
|
"""Copy the controller-side Tensor to worker-0.
|
|
|
|
Parameters
|
|
----------
|
|
host_array : Tensor
|
|
The array to be copied to worker-0.
|
|
|
|
remote_array : Optiona[DRef]
|
|
The destination Tensor on worker-0.
|
|
|
|
Returns
|
|
-------
|
|
output_array: DRef
|
|
|
|
The DRef containing the copied data on worker0, and
|
|
std::nullopt on all other workers. If `remote_array` was
|
|
provided, this return value is the same as `remote_array`.
|
|
Otherwise, it is the newly allocated space.
|
|
|
|
"""
|
|
if remote_array is None:
|
|
remote_array = self.empty(host_array.shape, host_array.dtype, worker0_only=True)
|
|
|
|
_ffi_api.SessionCopyToWorker0(self, host_array, remote_array) # type: ignore # pylint: disable=no-member
|
|
return remote_array
|
|
|
|
def load_vm_module(
|
|
self,
|
|
path: str,
|
|
device: Device | None = None,
|
|
) -> DModule:
|
|
"""Load a VM module from a file.
|
|
|
|
Parameters
|
|
----------
|
|
path : str
|
|
The path to the VM module file.
|
|
|
|
device : Optional[Device] = None
|
|
The device to load the VM module to. Default to the default device of each worker.
|
|
|
|
Returns
|
|
-------
|
|
module : DModule
|
|
The loaded VM module.
|
|
"""
|
|
func = self._get_cached_method("runtime.disco.load_vm_module")
|
|
return DModule(func(path, device), self)
|
|
|
|
def init_ccl(self, ccl: str, *device_ids):
|
|
"""Initialize the underlying communication collective library.
|
|
|
|
Parameters
|
|
----------
|
|
ccl : str
|
|
The name of the communication collective library. Currently supported libraries are:
|
|
- nccl
|
|
- rccl
|
|
- mpi
|
|
|
|
*device_ids : int
|
|
The device IDs to be used by the underlying communication library.
|
|
"""
|
|
assert ccl in ("nccl", "rccl"), f"Unsupported CCL backend: {ccl}"
|
|
_ffi_api.SessionInitCCL(self, ccl, Shape(device_ids)) # type: ignore # pylint: disable=no-member
|
|
self._clear_ipc_memory_pool()
|
|
|
|
def broadcast(
|
|
self,
|
|
src: np.ndarray | Tensor,
|
|
dst: DRef | None = None,
|
|
in_group: bool = True,
|
|
) -> DRef:
|
|
"""Broadcast an array to all workers
|
|
|
|
Parameters
|
|
----------
|
|
src: Union[np.ndarray, Tensor]
|
|
The array to be broadcasted.
|
|
|
|
dst: Optional[DRef]
|
|
The output array. If None, an array matching the shape
|
|
and dtype of `src` will be allocated on each worker.
|
|
|
|
in_group: bool
|
|
Whether the broadcast operation performs globally or in group as default.
|
|
|
|
Returns
|
|
-------
|
|
output_array: DRef
|
|
|
|
The DRef containing the broadcasted data on all workers.
|
|
If `dst` was provided, this return value is the same as
|
|
`dst`. Otherwise, it is the newly allocated space.
|
|
|
|
"""
|
|
if not isinstance(src, Tensor):
|
|
src = _as_Tensor(src)
|
|
|
|
if dst is None:
|
|
dst = self.empty(src.shape, src.dtype)
|
|
|
|
src_dref = self.copy_to_worker_0(src)
|
|
self.broadcast_from_worker0(src_dref, dst, in_group)
|
|
|
|
return dst
|
|
|
|
def broadcast_from_worker0(self, src: DRef, dst: DRef, in_group: bool = True) -> DRef:
|
|
"""Broadcast an array from worker-0 to all other workers.
|
|
|
|
Parameters
|
|
----------
|
|
src: Union[np.ndarray, Tensor]
|
|
The array to be broadcasted.
|
|
|
|
dst: Optional[DRef]
|
|
The output array. If None, an array matching the shape
|
|
and dtype of `src` will be allocated on each worker.
|
|
|
|
in_group: bool
|
|
Whether the broadcast operation performs globally or in group as default.
|
|
"""
|
|
func = self._get_cached_method("runtime.disco.broadcast_from_worker0")
|
|
func(src, in_group, dst)
|
|
|
|
def scatter(
|
|
self,
|
|
src: np.ndarray | Tensor,
|
|
dst: DRef | None = None,
|
|
in_group: bool = True,
|
|
) -> DRef:
|
|
"""Scatter an array across all workers
|
|
|
|
Parameters
|
|
----------
|
|
src: Union[np.ndarray, Tensor]
|
|
The array to be scattered. The first dimension of this
|
|
array, `src.shape[0]`, must be equal to the number of
|
|
workers.
|
|
|
|
dst: Optional[DRef]
|
|
The output array. If None, an array with compatible shape
|
|
and the same dtype as `src` will be allocated on each
|
|
worker.
|
|
|
|
in_group: bool
|
|
Whether the scatter operation performs globally or in group as default.
|
|
|
|
Returns
|
|
-------
|
|
output_array: DRef
|
|
|
|
The DRef containing the scattered data on all workers.
|
|
If `dst` was provided, this return value is the same as
|
|
`dst`. Otherwise, it is the newly allocated space.
|
|
|
|
"""
|
|
assert src.shape[0] == self.num_workers
|
|
|
|
if not isinstance(src, Tensor):
|
|
src = _as_Tensor(src)
|
|
|
|
if dst is None:
|
|
dst = self.empty(src.shape[1:], src.dtype)
|
|
|
|
src_dref = self.copy_to_worker_0(src)
|
|
self.scatter_from_worker0(src_dref, dst, in_group)
|
|
|
|
return dst
|
|
|
|
def scatter_from_worker0(self, from_array: DRef, to_array: DRef, in_group: bool = True) -> None:
|
|
"""Scatter an array from worker-0 to all other workers.
|
|
|
|
Parameters
|
|
----------
|
|
src: Union[np.ndarray, Tensor]
|
|
The array to be scattered. The first dimension of this
|
|
array, `src.shape[0]`, must be equal to the number of
|
|
workers.
|
|
|
|
dst: Optional[DRef]
|
|
The output array. If None, an array with compatible shape
|
|
and the same dtype as `src` will be allocated on each
|
|
worker.
|
|
|
|
in_group: bool
|
|
Whether the scatter operation performs globally or in group as default.
|
|
"""
|
|
func = self._get_cached_method("runtime.disco.scatter_from_worker0")
|
|
func(from_array, in_group, to_array)
|
|
|
|
def gather_to_worker0(self, from_array: DRef, to_array: DRef, in_group: bool = True) -> None:
|
|
"""Gather an array from all other workers to worker-0.
|
|
|
|
Parameters
|
|
----------
|
|
from_array : DRef
|
|
The array to be gathered from.
|
|
|
|
to_array : DRef
|
|
The array to be gathered to.
|
|
|
|
in_group: bool
|
|
Whether the gather operation performs globally or in group as default.
|
|
"""
|
|
func = self._get_cached_method("runtime.disco.gather_to_worker0")
|
|
func(from_array, in_group, to_array)
|
|
|
|
def allreduce(
|
|
self,
|
|
src: DRef,
|
|
dst: DRef,
|
|
op: str = "sum", # pylint: disable=invalid-name
|
|
in_group: bool = True,
|
|
) -> DRef:
|
|
"""Perform an allreduce operation on an array.
|
|
|
|
Parameters
|
|
----------
|
|
array : DRef
|
|
The array to be reduced.
|
|
|
|
op : str = "sum"
|
|
The reduce operation to be performed. Available options are:
|
|
- "sum"
|
|
- "prod"
|
|
- "min"
|
|
- "max"
|
|
- "avg"
|
|
|
|
in_group : bool
|
|
Whether the reduce operation performs globally or in group as default.
|
|
"""
|
|
if op not in REDUCE_OPS:
|
|
raise ValueError(f"Unsupported reduce op: {op}. Available ops are: {REDUCE_OPS.keys()}")
|
|
op = Shape([REDUCE_OPS[op]])
|
|
func = self._get_cached_method("runtime.disco.allreduce")
|
|
func(src, op, in_group, dst)
|
|
|
|
def allgather(
|
|
self,
|
|
src: DRef,
|
|
dst: DRef,
|
|
in_group: bool = True,
|
|
) -> DRef:
|
|
"""Perform an allgather operation on an array.
|
|
|
|
Parameters
|
|
----------
|
|
src : DRef
|
|
The array to be gathered from.
|
|
|
|
dst : DRef
|
|
The array to be gathered to.
|
|
|
|
in_group : bool
|
|
Whether the reduce operation performs globally or in group as default.
|
|
"""
|
|
func = self._get_cached_method("runtime.disco.allgather")
|
|
func(src, in_group, dst)
|
|
|
|
def _clear_ipc_memory_pool(self):
|
|
# Clear the IPC memory allocator when the allocator exists.
|
|
name = "runtime.disco.cuda_ipc.cuda_ipc_memory_allocator_clear"
|
|
if get_global_func(name, allow_missing=True) is not None:
|
|
self.call_packed(self.get_global_func(name))
|
|
|
|
|
|
@register_object("runtime.disco.ThreadedSession")
|
|
class ThreadedSession(Session):
|
|
"""A Disco session backed by multi-threading."""
|
|
|
|
def __init__(self, num_workers: int, num_groups: int = 1) -> None:
|
|
"""Create a disco session backed by multiple threads in the same process."""
|
|
self.__init_handle_by_constructor__(
|
|
_ffi_api.SessionThreaded, # type: ignore # pylint: disable=no-member
|
|
num_workers,
|
|
num_groups,
|
|
)
|
|
|
|
|
|
@register_object("runtime.disco.ProcessSession")
|
|
class ProcessSession(Session):
|
|
"""A Disco session backed by pipe-based multi-processing."""
|
|
|
|
def __init__(
|
|
self,
|
|
num_workers: int,
|
|
num_groups: int = 1,
|
|
entrypoint: str = "tvm.exec.disco_worker",
|
|
) -> None:
|
|
self.__init_handle_by_constructor__(
|
|
_ffi_api.SessionProcess, # type: ignore # pylint: disable=no-member
|
|
num_workers,
|
|
num_groups,
|
|
"runtime.disco.create_process_pool",
|
|
entrypoint,
|
|
)
|
|
self._configure_structlog()
|
|
|
|
def _configure_structlog(self) -> None:
|
|
try:
|
|
import structlog # pylint: disable=import-outside-toplevel
|
|
except ImportError:
|
|
return
|
|
|
|
root_logger = logging.getLogger()
|
|
if len(root_logger.handlers) == 1 and isinstance(
|
|
root_logger.handlers[0].formatter, structlog.stdlib.ProcessorFormatter
|
|
):
|
|
stdlib_formatter = root_logger.handlers[0].formatter
|
|
else:
|
|
stdlib_formatter = None
|
|
|
|
stdlib_level = root_logger.level
|
|
|
|
full_config = (structlog.get_config(), stdlib_formatter, stdlib_level)
|
|
|
|
config = pickle.dumps(full_config)
|
|
func = self.get_global_func("runtime.disco._configure_structlog")
|
|
func(config, os.getpid())
|
|
|
|
|
|
@register_global_func("runtime.disco.create_socket_session_local_workers")
|
|
def _create_socket_session_local_workers(num_workers) -> Session:
|
|
"""Create the local session for each distributed node over socket session."""
|
|
return ProcessSession(num_workers)
|
|
|
|
|
|
@register_object("runtime.disco.SocketSession")
|
|
class SocketSession(Session):
|
|
"""A Disco session backed by socket-based multi-node communication."""
|
|
|
|
def __init__(
|
|
self,
|
|
num_nodes: int,
|
|
num_workers_per_node: int,
|
|
num_groups: int,
|
|
host: str,
|
|
port: int,
|
|
) -> None:
|
|
self.__init_handle_by_constructor__(
|
|
_ffi_api.SocketSession, # type: ignore # pylint: disable=no-member
|
|
num_nodes,
|
|
num_workers_per_node,
|
|
num_groups,
|
|
host,
|
|
port,
|
|
)
|
|
|
|
|
|
@register_global_func("runtime.disco._configure_structlog")
|
|
def _configure_structlog(pickled_config: bytes, parent_pid: int) -> None:
|
|
"""Configure structlog for all disco workers
|
|
|
|
The child processes
|
|
|
|
Parameters
|
|
----------
|
|
pickled_config: bytes
|
|
|
|
The pickled configuration for structlog
|
|
|
|
parent_pid: int
|
|
|
|
The PID of the main process. This is used to restrict the
|
|
"""
|
|
if os.getpid() == parent_pid:
|
|
return
|
|
|
|
import structlog # pylint: disable=import-outside-toplevel
|
|
|
|
full_config = pickle.loads(pickled_config)
|
|
structlog_config, stdlib_formatter, stdlib_level = full_config
|
|
|
|
root_logger = logging.getLogger()
|
|
|
|
root_logger.setLevel(stdlib_level)
|
|
if stdlib_formatter is not None:
|
|
handler = logging.StreamHandler()
|
|
handler.setFormatter(stdlib_formatter)
|
|
root_logger.addHandler(handler)
|
|
|
|
structlog.configure(**structlog_config)
|
|
|
|
|
|
@register_global_func("runtime.disco._import_python_module")
|
|
def _import_python_module(module_name: str) -> None:
|
|
__import__(module_name)
|
|
|
|
|
|
REDUCE_OPS = {
|
|
"sum": 0,
|
|
"prod": 1,
|
|
"min": 2,
|
|
"max": 3,
|
|
"avg": 4,
|
|
}
|