# coding: utf-8 import os import sys from typing import Optional import pytest import torch import ray import ray.cluster_utils from ray.dag import InputNode, MultiOutputNode from ray.dag.compiled_dag_node import CompiledDAG from ray.dag.dag_node_operation import _DAGNodeOperationType from ray.tests.conftest import * # noqa if sys.platform != "linux" and sys.platform != "darwin": pytest.skip("Skipping, requires Linux or Mac.", allow_module_level=True) USE_GPU = os.environ.get("RAY_PYTEST_USE_GPU") == "1" if not USE_GPU: pytest.skip("Skipping, these tests require GPUs.", allow_module_level=True) @ray.remote(num_cpus=0, num_gpus=1) class Worker: def __init__(self, rank: Optional[int] = None): self.rank = rank self.trace = [] def fwd(self, value): self.trace.append(("FWD", self.rank)) return value def bwd(self, value): self.trace.append(("BWD", self.rank)) return value def pop_trace(self): trace = self.trace self.trace = [] return trace def read_input(self, input): return input def send(self, shape, dtype, value: int, send_tensor=True): if not send_tensor: return 1 return torch.ones(shape, dtype=dtype, device=self.device) * value def recv(self, tensor): # Check that tensor got loaded to the correct device. assert tensor.device == self.device return (tensor[0].item(), tensor.shape, tensor.dtype) def no_op(self, value): return value def no_op_two(self, value1, value2): return value1, value2 def generate_1f1b_dag( num_workers: int, num_microbatches: int, num_lead_microbatches: int ) -> CompiledDAG: workers = [Worker.remote(rank) for rank in range(num_workers)] with ray.dag.InputNode() as inp: fwd_queues = [[] for _ in range(num_workers)] bwd_queues = [[] for _ in range(num_workers)] # Once a worker's counter reaches 0, it cannot execute another fwd until it # executes a bwd first. fwd_counter = [num_lead_microbatches - i for i in range(num_workers)] # All of the done batches. done = [] # FWD on worker 0. input_data = workers[0].read_input.bind(inp) for i in range(num_microbatches): fwd_queues[0].append(input_data) while len(done) < num_microbatches: for i, worker in enumerate(workers): if fwd_counter[i] > 0 and fwd_queues[i]: b = fwd_queues[i].pop(0) b = worker.fwd.bind(b) if i < num_workers - 1: fwd_queues[i + 1].append(b) # Use NCCL channel for communication between workers. b.with_tensor_transport(transport="nccl") else: bwd_queues[i].append(b) fwd_counter[i] -= 1 elif bwd_queues[i]: b = bwd_queues[i].pop(0) b = worker.bwd.bind(b) if i > 0: bwd_queues[i - 1].append(b) # Use NCCL channel for communication between workers. b.with_tensor_transport(transport="nccl") else: done.append(b) fwd_counter[i] += 1 dag = ray.dag.MultiOutputNode(done) compiled_dag = dag.experimental_compile() return compiled_dag @pytest.mark.parametrize("ray_start_regular", [{"num_gpus": 2}], indirect=True) @pytest.mark.parametrize("single_fetch", [True, False]) def test_simulate_pp_2workers_2batches_1f1b( ray_start_regular, single_fetch, monkeypatch ): """ This test simulates a simple 1F1B pipeline parallelism for training with 2 workers and 2 batches. w1: fwd_b1 fwd_b2 bwd_b1 bwd_b2 w2: fwd_b1 bwd_b1 fwd_b2 bwd_b2 The communication between workers is done using NCCL. The communication within the worker actor is done using IntraProcessChannel. """ if not USE_GPU: pytest.skip("NCCL tests require GPUs") w1 = Worker.remote() w2 = Worker.remote() with InputNode() as inp: w1_input = w1.read_input.bind(inp) batch_1 = w1.fwd.bind(w1_input) batch_1.with_tensor_transport(transport="nccl") batch_2 = w1.fwd.bind(w1_input) batch_2.with_tensor_transport(transport="nccl") batch_1 = w2.fwd.bind(batch_1) batch_1 = w2.bwd.bind(batch_1) batch_1.with_tensor_transport(transport="nccl") batch_2 = w2.fwd.bind(batch_2) batch_1 = w1.bwd.bind(batch_1) batch_2 = w2.bwd.bind(batch_2) batch_2.with_tensor_transport(transport="nccl") batch_2 = w1.bwd.bind(batch_2) dag = MultiOutputNode([batch_1, batch_2]) compiled_dag = dag.experimental_compile() w1_expected_schedule = [ (0, _DAGNodeOperationType.READ), (0, _DAGNodeOperationType.COMPUTE), (0, _DAGNodeOperationType.WRITE), (1, _DAGNodeOperationType.READ), (1, _DAGNodeOperationType.COMPUTE), (1, _DAGNodeOperationType.WRITE), # `w1 (3, READ)` (P2P recv) is scheduled together with # `w2 (1, WRITE)` (P2P send). (3, _DAGNodeOperationType.READ), (2, _DAGNodeOperationType.READ), (2, _DAGNodeOperationType.COMPUTE), (2, _DAGNodeOperationType.WRITE), # `w1 (4, READ)` (P2P recv) is scheduled together with # `w2 (3, WRITE)` (P2P send). (4, _DAGNodeOperationType.READ), (3, _DAGNodeOperationType.COMPUTE), (3, _DAGNodeOperationType.WRITE), (4, _DAGNodeOperationType.COMPUTE), (4, _DAGNodeOperationType.WRITE), ] w2_expected_schedule = [ (0, _DAGNodeOperationType.READ), (0, _DAGNodeOperationType.COMPUTE), (0, _DAGNodeOperationType.WRITE), (1, _DAGNodeOperationType.READ), (1, _DAGNodeOperationType.COMPUTE), (1, _DAGNodeOperationType.WRITE), (2, _DAGNodeOperationType.READ), (2, _DAGNodeOperationType.COMPUTE), (2, _DAGNodeOperationType.WRITE), (3, _DAGNodeOperationType.READ), (3, _DAGNodeOperationType.COMPUTE), (3, _DAGNodeOperationType.WRITE), ] w1_schedule = compiled_dag.actor_to_execution_schedule[w1] w2_schedule = compiled_dag.actor_to_execution_schedule[w2] for schedule, expected_schedule in zip( [w1_schedule, w2_schedule], [w1_expected_schedule, w2_expected_schedule] ): assert len(schedule) == len(expected_schedule) for i, operation in enumerate(schedule): assert operation.exec_task_idx == expected_schedule[i][0] assert operation.type == expected_schedule[i][1] tensor_cpu = torch.zeros(10, 10) tensor_cuda = tensor_cpu.to("cuda:0") refs = compiled_dag.execute(tensor_cuda) if single_fetch: assert len(refs) == 2 for ref in refs: tensor = ray.get(ref) assert torch.equal(tensor.cpu(), tensor_cpu) else: tensors = ray.get(refs) assert len(tensors) == 2 for tensor in tensors: assert torch.equal(tensor.cpu(), tensor_cpu) @pytest.mark.parametrize("ray_start_regular", [{"num_gpus": 4}], indirect=True) def test_simulate_pp_4workers_8batches_1f1b(ray_start_regular, monkeypatch): """ This test simulates a 1F1B pipeline parallelism for training with 4 workers and 8 batches. """ if not USE_GPU: pytest.skip("NCCL tests require GPUs") num_workers, num_microbatches, num_lead_microbatches = 4, 8, 4 compiled_dag = generate_1f1b_dag( num_workers, num_microbatches, num_lead_microbatches ) tensor_cpu = torch.zeros(10, 10) tensor_cuda = tensor_cpu.to("cuda:0") tensors = ray.get(compiled_dag.execute(tensor_cuda)) assert len(tensors) == num_microbatches for t in tensors: assert torch.equal(t.cpu(), tensor_cpu) @pytest.mark.parametrize("ray_start_regular", [{"num_gpus": 3}], indirect=True) def test_three_actors_with_nccl_1(ray_start_regular): """ Driver -> a.no_op -> b.no_op -> a.no_op_two -> Driver | | -> c.no_op - """ if not USE_GPU: pytest.skip("NCCL tests require GPUs") a = Worker.remote() b = Worker.remote() c = Worker.remote() with InputNode() as inp: dag = a.no_op.bind(inp) dag.with_tensor_transport(transport="nccl") branch1 = b.no_op.bind(dag) branch1.with_tensor_transport(transport="nccl") branch2 = c.no_op.bind(dag) branch2.with_tensor_transport(transport="nccl") dag = a.no_op_two.bind(branch1, branch2) compiled_dag = dag.experimental_compile() a_expected_schedule = [ (0, _DAGNodeOperationType.READ), (0, _DAGNodeOperationType.COMPUTE), (0, _DAGNodeOperationType.WRITE), (1, _DAGNodeOperationType.READ), (1, _DAGNodeOperationType.COMPUTE), (1, _DAGNodeOperationType.WRITE), ] b_expected_schedule = [ (0, _DAGNodeOperationType.READ), (0, _DAGNodeOperationType.COMPUTE), (0, _DAGNodeOperationType.WRITE), ] c_expected_schedule = [ (0, _DAGNodeOperationType.READ), (0, _DAGNodeOperationType.COMPUTE), (0, _DAGNodeOperationType.WRITE), ] a_schedule = compiled_dag.actor_to_execution_schedule[a] b_schedule = compiled_dag.actor_to_execution_schedule[b] c_schedule = compiled_dag.actor_to_execution_schedule[c] for schedule, expected_schedule in zip( [a_schedule, b_schedule, c_schedule], [a_expected_schedule, b_expected_schedule, c_expected_schedule], ): assert len(schedule) == len(expected_schedule) for i, operation in enumerate(schedule): assert operation.exec_task_idx == expected_schedule[i][0] assert operation.type == expected_schedule[i][1] tensor_cpu = torch.zeros(10, 10) tensor_cuda = tensor_cpu.to("cuda:0") ref = compiled_dag.execute(tensor_cuda) tensors = ray.get(ref) assert len(tensors) == 2 for t in tensors: assert torch.equal(t.cpu(), tensor_cpu) @pytest.mark.parametrize("ray_start_regular", [{"num_gpus": 3}], indirect=True) @pytest.mark.parametrize("single_fetch", [True, False]) def test_three_actors_with_nccl_2(ray_start_regular, single_fetch, monkeypatch): """ Driver --> a.no_op -> b.no_op --> Driver | | -> b.no_op -> c.no_op - | | -> c.no_op -> a.no_op - """ if not USE_GPU: pytest.skip("NCCL tests require GPUs") a = Worker.remote() b = Worker.remote() c = Worker.remote() with InputNode() as inp: branch1 = a.no_op.bind(inp) branch1.with_tensor_transport(transport="nccl") branch2 = b.no_op.bind(inp) branch2.with_tensor_transport(transport="nccl") branch3 = c.no_op.bind(inp) branch3.with_tensor_transport(transport="nccl") dag = MultiOutputNode( [ a.no_op.bind(branch3), b.no_op.bind(branch1), c.no_op.bind(branch2), ] ) compiled_dag = dag.experimental_compile() a_expected_schedule = [ (0, _DAGNodeOperationType.READ), (0, _DAGNodeOperationType.COMPUTE), (0, _DAGNodeOperationType.WRITE), (1, _DAGNodeOperationType.READ), (1, _DAGNodeOperationType.COMPUTE), (1, _DAGNodeOperationType.WRITE), ] b_expected_schedule = [ # `b (1, READ)` (P2P recv) is scheduled together with # `a (0, WRITE)` (P2P send). (1, _DAGNodeOperationType.READ), (0, _DAGNodeOperationType.READ), (0, _DAGNodeOperationType.COMPUTE), (0, _DAGNodeOperationType.WRITE), (1, _DAGNodeOperationType.COMPUTE), (1, _DAGNodeOperationType.WRITE), ] c_expected_schedule = [ # `c (1, READ)` (P2P recv) is scheduled together with # `a (0, WRITE)` (P2P send). (1, _DAGNodeOperationType.READ), (0, _DAGNodeOperationType.READ), (0, _DAGNodeOperationType.COMPUTE), (0, _DAGNodeOperationType.WRITE), (1, _DAGNodeOperationType.COMPUTE), (1, _DAGNodeOperationType.WRITE), ] a_schedule = compiled_dag.actor_to_execution_schedule[a] b_schedule = compiled_dag.actor_to_execution_schedule[b] c_schedule = compiled_dag.actor_to_execution_schedule[c] for schedule, expected_schedule in zip( [a_schedule, b_schedule, c_schedule], [a_expected_schedule, b_expected_schedule, c_expected_schedule], ): assert len(schedule) == len(expected_schedule) for i, operation in enumerate(schedule): assert operation.exec_task_idx == expected_schedule[i][0] assert operation.type == expected_schedule[i][1] tensor_cpu = torch.zeros(10, 10) tensor_cuda = tensor_cpu.to("cuda:0") refs = compiled_dag.execute(tensor_cuda) if single_fetch: assert len(refs) == 3 for ref in refs: tensor = ray.get(ref) assert torch.equal(tensor.cpu(), tensor_cpu) else: tensors = ray.get(refs) assert len(tensors) == 3 for tensor in tensors: assert torch.equal(tensor.cpu(), tensor_cpu) @pytest.mark.parametrize("ray_start_regular", [{"num_gpus": 3}], indirect=True) @pytest.mark.parametrize("overlap_gpu_communication", [True, False]) def test_overlap_gpu_communication(ray_start_regular, overlap_gpu_communication): """ Driver --> sender1.send -> receiver.recv --> Driver | | -> sender2.send -> receiver.recv - """ if not USE_GPU: pytest.skip("NCCL tests require GPUs") sender1 = Worker.remote() sender2 = Worker.remote() receiver = Worker.remote() shape = (10000,) dtype = torch.float16 with InputNode() as inp: branch1 = sender1.send.bind(shape, dtype, inp) branch1 = branch1.with_tensor_transport( transport="nccl", _static_shape=True, _direct_return=True ) branch1 = receiver.recv.bind(branch1) branch2 = sender2.send.bind(shape, dtype, inp) branch2 = branch2.with_tensor_transport( transport="nccl", _static_shape=True, _direct_return=True ) branch2 = receiver.recv.bind(branch2) dag = MultiOutputNode([branch1, branch2]) # Test normal execution. compiled_dag = dag.experimental_compile( _overlap_gpu_communication=overlap_gpu_communication ) # Check receiver schedule expected_no_overlap_schedule = [ (0, _DAGNodeOperationType.READ), # `receiver (1, READ)` (P2P recv) is scheduled together with # `sender2 (0, WRITE)` (P2P send). (1, _DAGNodeOperationType.READ), (0, _DAGNodeOperationType.COMPUTE), (0, _DAGNodeOperationType.WRITE), (1, _DAGNodeOperationType.COMPUTE), (1, _DAGNodeOperationType.WRITE), ] expected_overlap_schedule = [ (0, _DAGNodeOperationType.READ), # `receiver (1, READ)` (P2P recv) is scheduled together with # `sender2 (0, WRITE)` (P2P send). (1, _DAGNodeOperationType.READ), (0, _DAGNodeOperationType.COMPUTE), (0, _DAGNodeOperationType.WRITE), (1, _DAGNodeOperationType.COMPUTE), (1, _DAGNodeOperationType.WRITE), ] if overlap_gpu_communication: expected_receiver_schedule = expected_overlap_schedule else: expected_receiver_schedule = expected_no_overlap_schedule receiver_schedule = compiled_dag.actor_to_execution_schedule[receiver] assert len(receiver_schedule) == len(expected_receiver_schedule) for i, operation in enumerate(receiver_schedule): assert operation.exec_task_idx == expected_receiver_schedule[i][0] assert operation.type == expected_receiver_schedule[i][1] compiled_dag.teardown() if __name__ == "__main__": if os.environ.get("PARALLEL_CI"): sys.exit(pytest.main(["-n", "auto", "--boxed", "-vs", __file__])) else: sys.exit(pytest.main(["-sv", __file__]))