NVIDIA-NeMo/Speech · #16069

Improve distributed dataloader handling

pzelasko · merged Nov 6, 20263 files · 285 + / 35 −
docs/source/dataloaders.rst3 + / 2 −
@@ -1482,8 +1482,9 @@ The :class:`~nemo.collections.common.data.lhotse.broadcasting.BroadcastingDataLo fixes this at the data layer: construct the real Lhotse loader on a single DP-source rank (``cp_rank == 0`` and ``tp_rank == 0``) and let the wrapper broadcast each batch to the other ranks in the ``(cp, tp)``-sub-mesh over NCCL. Iteration ends in lockstep via a continue/stop-broadcast — no length needs to be known up-front.+sub-mesh over NCCL. Each step broadcasts one framed message containing a+batch, clean-exhaustion signal, or source-side error, so all ranks finish+or fail in lockstep without requiring the loader length up-front.  .. code-block:: python 
nemo/collections/common/data/lhotse/broadcasting.py112 + / 27 −
@@ -40,13 +40,15 @@ ``torchdata.StatefulDataLoader``, or any other source object that implements those methods. -Iteration termination is handled with two broadcasts per step: a-continue/stop boolean followed by the batch. This works regardless of+Each iteration broadcasts one framed message containing a data batch, a stop+signal, or details about a source-side error. This works regardless of whether the source loader exposes ``__len__`` (Lhotse training loaders-typically don't).+typically don't) and prevents receivers from waiting indefinitely when the+source loader fails. """ from __future__ import annotations +import pickle from typing import Any, Iterable, Iterator, Sequence  import torch@@ -102,9 +104,8 @@ def broadcast_batch(     if resolved is None:         return batch     group, src = resolved-    obj_list = [batch]-    dist.broadcast_object_list(obj_list, src=src, group=group, device=_broadcast_device(group))-    return obj_list[0]+    packet = (_PACKET_DATA, batch) if dist.get_rank() == src else None+    return _packet_payload(_broadcast_packet(packet, group, src))   class BroadcastingDataLoader:@@ -114,10 +115,10 @@ class BroadcastingDataLoader:      Pass ``source=real_loader`` on the DP source rank (``cp_rank == 0`` and     ``tp_rank == 0``); pass ``source=None`` on every other rank. Iteration-    issues two broadcasts per step on every rank: a continue/stop boolean-    followed by the batch. After the source loader is exhausted, the-    continue broadcast is False and iteration ends in lockstep on all-    ranks regardless of whether the source exposes ``__len__``.+    issues one framed broadcast per step on every rank. The frame contains a+    data batch, clean-exhaustion signal, or source-side error. This lets all+    ranks finish or fail in lockstep regardless of whether the source exposes+    ``__len__``.      ``state_dict`` / ``load_state_dict`` are delegated to the source on the     source rank (no-ops on non-source ranks), so checkpoint/resume keeps@@ -138,6 +139,7 @@ def __init__(         self._source = source         self._mesh = device_mesh         self._axes = axes+        self._group_and_source = None         if not _is_noop(device_mesh, axes):             self._is_source = is_dp_source_rank(device_mesh, axes)             if self._is_source and source is None:@@ -149,19 +151,45 @@ def __iter__(self) -> Iterator[Any]:                 return             yield from self._source             return+        if not (dist.is_available() and dist.is_initialized()):+            if self._source is None:+                return+            yield from self._source+            return++        if self._group_and_source is None:+            self._group_and_source = _resolve_group_and_source(self._mesh, self._axes)+        if self._group_and_source is None:+            if self._source is None:+                return+            yield from self._source+            return++        group, src = self._group_and_source         if self._is_source:-            for batch in self._source:-                broadcast_batch(True, self._mesh, self._axes)-                broadcast_batch(batch, self._mesh, self._axes)+            try:+                source_iterator = iter(self._source)+            except Exception as error:+                _broadcast_packet(_error_packet(error, "source iterator creation failed"), group, src)+                raise+            while True:+                try:+                    batch = next(source_iterator)+                except StopIteration:+                    _broadcast_packet((_PACKET_STOP, None), group, src)+                    return+                except Exception as error:+                    _broadcast_packet(_error_packet(error, "source iterator failed"), group, src)+                    raise++                _broadcast_packet((_PACKET_DATA, batch), group, src)                 yield batch-            broadcast_batch(False, self._mesh, self._axes)         else:             while True:-                keep_iterating = broadcast_batch(None, self._mesh, self._axes)-                if not keep_iterating:+                packet = _broadcast_packet(None, group, src)+                if packet[0] == _PACKET_STOP:                     return-                batch = broadcast_batch(None, self._mesh, self._axes)-                yield batch+                yield _packet_payload(packet)      def __len__(self) -> int:         # Pass-through when the source defines __len__; raise TypeError@@ -186,10 +214,9 @@ def load_state_dict(self, state_dict) -> None: # ---------------------------------------------------------------------------  -# Cache: (id(device_mesh), tuple_of_axes) -> (process_group, source_global_rank).-# Sub-mesh creation calls ``_flatten`` which materializes a process group;-# we don't want to repeat that per training step.-_GROUP_CACHE: dict[tuple[int, tuple[str, ...]], tuple[Any, int]] = {}+_PACKET_DATA = "data"+_PACKET_STOP = "stop"+_PACKET_ERROR = "error"   def _present_axes(device_mesh, axes: Sequence[str]) -> tuple[str, ...]:@@ -219,10 +246,6 @@ def _resolve_group_and_source(device_mesh, axes: Sequence[str]):     if _is_noop(device_mesh, axes):         return None     present = _present_axes(device_mesh, axes)-    cache_key = (id(device_mesh), present)-    cached = _GROUP_CACHE.get(cache_key)-    if cached is not None:-        return cached      if len(present) == 1:         sub = device_mesh[present[0]]@@ -231,5 +254,67 @@ def _resolve_group_and_source(device_mesh, axes: Sequence[str]):      group = sub.get_group()     source_global_rank = int(sub.mesh.flatten()[0].item())-    _GROUP_CACHE[cache_key] = (group, source_global_rank)     return group, source_global_rank+++def _broadcast_packet(packet, group, src: int):+    """Broadcast one pre-serialized protocol packet over ``group``.++    Serializing before the first tensor collective lets the source replace an+    unpicklable data packet with a small error packet while receivers are still+    waiting for the packet size. Failures after a collective begins must be+    handled by the process-group timeout and distributed job teardown.+    """+    is_source = dist.get_rank() == src+    source_error = None+    if is_source:+        try:+            serialized = pickle.dumps(packet, protocol=pickle.HIGHEST_PROTOCOL)+        except Exception as error:+            source_error = error+            packet = _error_packet(error, "batch serialization failed")+            serialized = pickle.dumps(packet, protocol=pickle.HIGHEST_PROTOCOL)+    else:+        serialized = b""++    device = _broadcast_device(group)+    serialized_size = torch.tensor([len(serialized)], dtype=torch.long, device=device)+    dist.broadcast(serialized_size, src=src, group=group)++    if is_source:+        serialized_buffer = bytearray(serialized)+        serialized_tensor = torch.frombuffer(serialized_buffer, dtype=torch.uint8).to(device)+    else:+        serialized_tensor = torch.empty(int(serialized_size.item()), dtype=torch.uint8, device=device)+    dist.broadcast(serialized_tensor, src=src, group=group)++    if source_error is not None:+        raise source_error+    if not is_source:+        packet = pickle.loads(serialized_tensor.cpu().numpy().tobytes())+    _validate_packet(packet)+    return packet+++def _packet_payload(packet):+    kind, payload = packet+    if kind == _PACKET_ERROR:+        raise RuntimeError(f"BroadcastingDataLoader source error: {payload}")+    if kind != _PACKET_DATA:+        raise RuntimeError(f"Expected a data packet, received {kind!r}")+    return payload+++def _error_packet(error: Exception, context: str):+    try:+        error_text = f"{type(error).__module__}.{type(error).__qualname__}: {error}"+    except Exception:+        error_text = f"{type(error).__module__}.{type(error).__qualname__}"+    return _PACKET_ERROR, f"{context}: {error_text}"+++def _validate_packet(packet) -> None:+    if not isinstance(packet, tuple) or len(packet) != 2:+        raise RuntimeError(f"Received an invalid broadcast packet: {type(packet).__name__}")+    if packet[0] not in (_PACKET_DATA, _PACKET_STOP, _PACKET_ERROR):+        raise RuntimeError(f"Received an unknown broadcast packet kind: {packet[0]!r}")
tests/collections/common/data/lhotse/test_broadcasting.py170 + / 6 −
@@ -30,6 +30,7 @@ import torch.distributed as dist import torch.multiprocessing as mp +from nemo.collections.common.data.lhotse import broadcasting from nemo.collections.common.data.lhotse.broadcasting import BroadcastingDataLoader, broadcast_batch, is_dp_source_rank  # ---------------------------------------------------------------------------@@ -38,30 +39,42 @@   class _FakeAxis:-    def __init__(self, size: int, local_rank: int):+    def __init__(self, size: int, local_rank: int, group=None, ranks=None):         self._size = size         self._local_rank = local_rank+        self._group = group+        self.mesh = torch.tensor(range(size) if ranks is None else ranks)      def size(self) -> int:         return self._size      def get_local_rank(self) -> int:         return self._local_rank +    def get_group(self):+        return self._group+  class _FakeMesh:     """Minimal DeviceMesh stand-in covering ``mesh_dim_names`` + ``__getitem__``.""" -    def __init__(self, sizes: dict[str, int], coords: dict[str, int]):+    def __init__(self, sizes: dict[str, int], coords: dict[str, int], groups=None, ranks=None):         assert sizes.keys() == coords.keys()         self.mesh_dim_names = tuple(sizes.keys())         self._sizes = sizes         self._coords = coords+        self._groups = groups or {}+        self._ranks = ranks or {}      def __getitem__(self, name):         if isinstance(name, tuple):             raise NotImplementedError("multi-axis slicing not needed for fake-mesh tests")-        return _FakeAxis(self._sizes[name], self._coords[name])+        return _FakeAxis(+            self._sizes[name],+            self._coords[name],+            group=self._groups.get(name),+            ranks=self._ranks.get(name),+        )   def test_is_dp_source_rank_none_mesh():@@ -109,6 +122,27 @@ def test_broadcast_batch_noop_when_axes_size_one():     assert broadcast_batch(payload, mesh) is payload  +def test_group_resolution_does_not_cache_by_object_id(monkeypatch):+    group_a = object()+    group_b = object()+    mesh_a = _FakeMesh(+        {"cp": 2},+        {"cp": 0},+        groups={"cp": group_a},+        ranks={"cp": [2, 3]},+    )+    mesh_b = _FakeMesh(+        {"cp": 2},+        {"cp": 0},+        groups={"cp": group_b},+        ranks={"cp": [6, 7]},+    )+    monkeypatch.setattr(broadcasting, "id", lambda _: 1, raising=False)++    assert broadcasting._resolve_group_and_source(mesh_a, ("cp",)) == (group_a, 2)+    assert broadcasting._resolve_group_and_source(mesh_b, ("cp",)) == (group_b, 6)++ def test_broadcasting_dataloader_noop_iterates_source():     real = [{"i": i} for i in range(4)]     loader = BroadcastingDataLoader(source=real, device_mesh=None)@@ -221,21 +255,91 @@ def _broadcasting_loader_worker(rank: int, world_size: int, port: int, queue: mp             dist.destroy_process_group()  -def _spawn_workers(target, world_size: int) -> list:+class _RaisesAfterOneBatch:+    def __iter__(self):+        yield {"i": 0}+        raise ValueError("source iterator exploded")+++class _RaisesOnIteratorCreation:+    def __iter__(self):+        raise ValueError("iterator creation failed")+++class _UnpicklableBatch:+    def __reduce__(self):+        raise TypeError("batch is intentionally unpicklable")+++def _source_iterator_error_worker(+    rank: int, world_size: int, port: int, queue: mp.Queue, source_type=_RaisesAfterOneBatch+) -> None:+    try:+        _init_gloo(rank, world_size, port)+        mesh = _build_cp_mesh(world_size)+        source = source_type() if is_dp_source_rank(mesh) else None+        loader = BroadcastingDataLoader(source=source, device_mesh=mesh)+        received = []+        try:+            received.extend(batch["i"] for batch in loader)+        except Exception as error:+            queue.put(("err", rank, received, type(error).__name__, str(error)))+        else:+            queue.put(("unexpected-success", rank, received, None, None))+    finally:+        if dist.is_initialized():+            dist.destroy_process_group()+++def _serialization_error_worker(rank: int, world_size: int, port: int, queue: mp.Queue) -> None:+    try:+        _init_gloo(rank, world_size, port)+        mesh = _build_cp_mesh(world_size)+        source = [_UnpicklableBatch()] if is_dp_source_rank(mesh) else None+        loader = BroadcastingDataLoader(source=source, device_mesh=mesh)+        try:+            list(loader)+        except Exception as error:+            queue.put(("err", rank, type(error).__name__, str(error)))+        else:+            queue.put(("unexpected-success", rank, None, None))+    finally:+        if dist.is_initialized():+            dist.destroy_process_group()+++def _early_break_worker(rank: int, world_size: int, port: int, queue: mp.Queue) -> None:+    try:+        _init_gloo(rank, world_size, port)+        mesh = _build_cp_mesh(world_size)+        source = [{"i": i} for i in range(3)] if is_dp_source_rank(mesh) else None+        loader = BroadcastingDataLoader(source=source, device_mesh=mesh)+        received = []+        for batch in loader:+            received.append(batch["i"])+            break+        queue.put(("ok", rank, received))+    finally:+        if dist.is_initialized():+            dist.destroy_process_group()+++def _spawn_workers(target, world_size: int, timeout: int = 120) -> list:     ctx = mp.get_context("spawn")     queue = ctx.Queue()     port = _get_free_port()     procs = [ctx.Process(target=target, args=(rank, world_size, port, queue)) for rank in range(world_size)]     for p in procs:         p.start()     for p in procs:-        p.join(timeout=120)+        p.join(timeout=timeout)     results = []     while not queue.empty():         results.append(queue.get())     for p in procs:-        if p.exitcode != 0 and p.is_alive():+        if p.is_alive():             p.terminate()+            p.join(timeout=5)     return results  @@ -254,3 +358,63 @@ def test_broadcasting_dataloader_iterates_in_lockstep_across_ranks():     for status, received in results:         assert status == "ok", results         assert received == [0, 1, 2]+++def test_broadcasting_dataloader_propagates_source_iterator_error():+    results = _spawn_workers(_source_iterator_error_worker, world_size=2, timeout=15)+    assert len(results) == 2, results+    results_by_rank = {result[1]: result for result in results}+    assert results_by_rank[0][:4] == ("err", 0, [0], "ValueError")+    assert "source iterator exploded" in results_by_rank[0][4]+    assert results_by_rank[1][:4] == ("err", 1, [0], "RuntimeError")+    assert "source iterator failed" in results_by_rank[1][4]+    assert "source iterator exploded" in results_by_rank[1][4]+++def test_broadcasting_dataloader_propagates_batch_serialization_error():+    results = _spawn_workers(_serialization_error_worker, world_size=2, timeout=15)+    assert len(results) == 2, results+    results_by_rank = {result[1]: result for result in results}+    assert results_by_rank[0][:3] == ("err", 0, "TypeError")+    assert "batch is intentionally unpicklable" in results_by_rank[0][3]+    assert results_by_rank[1][:3] == ("err", 1, "RuntimeError")+    assert "batch serialization failed" in results_by_rank[1][3]+    assert "batch is intentionally unpicklable" in results_by_rank[1][3]+++def test_broadcasting_dataloader_allows_synchronized_early_break():+    results = _spawn_workers(_early_break_worker, world_size=2, timeout=15)+    assert len(results) == 2, results+    for status, _, received in results:+        assert status == "ok", results+        assert received == [0]+++def test_broadcasting_dataloader_reports_iterator_creation_failure(monkeypatch):+    class FailingSource:+        def __iter__(self):+            raise ValueError('iterator creation failed')++    mesh = _FakeMesh({'cp': 2}, {'cp': 0}, groups={'cp': object()}, ranks={'cp': [0, 1]})+    monkeypatch.setattr(dist, 'is_initialized', lambda: True)+    packets = []+    monkeypatch.setattr(broadcasting, '_broadcast_packet', lambda packet, group, src: packets.append(packet))+    with pytest.raises(ValueError, match='iterator creation failed'):+        list(BroadcastingDataLoader(FailingSource(), mesh))+    assert len(packets) == 1+    assert packets[0][0] == 'error'+    assert 'iterator creation failed' in packets[0][1]+++def _source_iterator_creation_error_worker(rank, world_size, port, queue):+    _source_iterator_error_worker(rank, world_size, port, queue, source_type=_RaisesOnIteratorCreation)+++def test_broadcasting_dataloader_propagates_iterator_creation_error():+    results = _spawn_workers(_source_iterator_creation_error_worker, world_size=2, timeout=15)+    assert len(results) == 2, results+    results_by_rank = {result[1]: result for result in results}+    assert results_by_rank[0][:4] == ("err", 0, [], "ValueError")+    assert "iterator creation failed" in results_by_rank[0][4]+    assert results_by_rank[1][:4] == ("err", 1, [], "RuntimeError")+    assert "source iterator creation failed" in results_by_rank[1][4]