Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions docs/training/broadcast-data-across-tp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Broadcast Data Across Tensor-Parallel Ranks

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thank you for adding this documentation! I believe it'd be useful to have this be part of a general data loading page, which can later be expanded to cover different datasets supported in megatron bridge and how to plug in custom datasets, rather than a page specicially dedicated to TP broadcast vs replicated load, but I'll defer to @yaoyu-33 and @cuichenx for this


## Feature overview

The `broadcast_data_across_tp` flag controls how data is loaded across
tensor-parallel (TP) ranks during pretraining.

| Mode | `broadcast_data_across_tp` | Behaviour |
|---|---|---|
| Replicated (default) | `False` | Every TP rank independently builds a DataLoader and reads from storage. |
| Broadcast | `True` | Only TP-rank-0 reads from storage; the batch is broadcast to the remaining TP ranks via NCCL. |

**When to enable broadcast mode:**
Replicated loading works well on low-latency parallel file-systems such as
Lustre. On high-latency, network-attached storage (e.g. VAST) the redundant
I/O from all TP ranks causes severe contention -- `open()` syscall latency
degrades to ~1 s per call with 64+ concurrent processes. Enabling
`broadcast_data_across_tp` reduces VAST readers by a factor of TP and
eliminates the stalls.

### Configuration

```yaml
dataset:
broadcast_data_across_tp: true
```

## Implementation details

When the flag is enabled two things change:

1. **Dataset building** (`data/utils.py`): `BlendedMegatronDatasetBuilder`
receives `is_dataset_built_on_rank` instead of `lambda: True`, so the
dataset is only constructed on TP-rank-0 of first/last PP stages.

2. **Batch loading** (`gpt_step.py` / `batch_utils.py`):
`get_batch_on_this_tp_rank` is called instead of `get_batch_from_iterator`.
The five standard fixed-shape tensors (tokens, labels, loss_mask,
attention_mask, position_ids) are broadcast via direct NCCL `broadcast`
calls. Any additional keys (e.g. packed-sequence metadata) are forwarded
via `broadcast_object_list`, guarded by a lightweight boolean flag so the
heavy path is skipped when there are no extra keys.

## Performance benchmark

### Methodology

- **Hardware:** 8x NVIDIA B200 (single node, NVLink interconnect)
- **Software:** PyTorch with NCCL backend, `torchrun --nproc_per_node=8`
- **Measurement:** Median of 50 iterations after 10 warmup iterations per
configuration. Each iteration includes data loading on rank 0, NCCL
broadcast, and `.cuda()` transfer on receivers. `torch.cuda.synchronize()`
bookends each iteration.

### How to reproduce

```bash
torchrun --nproc_per_node=8 tests/benchmarks/bench_broadcast_tp.py
```

### Results (8x B200)

| Config | Data | Direct (ms) | ObjList (ms) | Overhead |
|--------------------------|--------|--------------|---------------|-----------|
| mbs=1 seq=8K | 0.2 MB | 0.14 | 0.62 | +0.48 |
| mbs=1 seq=8K +extra | 0.2 MB | 0.16 | 0.64 | +0.49 |
| mbs=1 seq=32K | 0.9 MB | 0.18 | 0.89 | +0.70 |
| mbs=1 seq=32K +extra | 0.9 MB | 0.21 | 0.90 | +0.70 |
| mbs=1 seq=128K | 3.7 MB | 0.36 | 1.86 | +1.50 |
| mbs=1 seq=256K | 7.3 MB | 0.57 | 3.58 | +3.01 |

**Direct broadcast** is the path taken for the standard batch keys.
**ObjList** is the fallback used only for extra keys (packed-sequence
metadata); in the common pretraining case this path is skipped entirely.

At 256K sequence length the direct broadcast adds < 0.6 ms per step --
negligible compared to a typical 22 s compute-bound step.

## Test coverage

### Unit tests

```bash
pytest tests/unit_tests/training/utils/test_batch_utils.py -v
```

| Test | What it validates |
|---|---|
| `test_standard_keys_returned` | TP rank 0 loads data; all 5 standard keys present in result. |
| `test_extra_keys_broadcast` | Extra keys (e.g. `cu_seqlens`) forwarded via `broadcast_object_list`. |
| `test_no_extra_keys_skips_heavy_broadcast` | When only standard keys exist, the heavy `broadcast_object_list` is skipped (only the boolean flag is broadcast). |

All tests use mocked `torch.distributed` and run on CPU without GPUs.

### Regression checklist

Manual validation steps for multi-GPU environments:

- [ ] Standard pretraining (TP=8, CP=1) -- no deadlocks, loss matches baseline
- [ ] Pretraining with context parallelism (TP=4, CP=2) -- no deadlocks
- [ ] Pretraining with MTP enabled -- tokens/position_ids broadcast to last PP stage
- [ ] `broadcast_data_across_tp=False` (default) -- behaviour unchanged from main
- [ ] Single DataLoader worker per rank on VAST storage -- no I/O stalls
- [ ] Lustre-backed storage -- no regression in step time
25 changes: 20 additions & 5 deletions src/megatron/bridge/data/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from __future__ import annotations

from dataclasses import fields
from typing import Any, Callable, Dict, Optional, Type, Union

from megatron.core import parallel_state
from megatron.core.datasets.blended_megatron_dataset_builder import BlendedMegatronDatasetBuilder
from megatron.core.datasets.blended_megatron_dataset_config import BlendedMegatronDatasetConfig
from megatron.core.datasets.gpt_dataset import GPTDataset, MockGPTDataset
Expand All @@ -37,17 +40,27 @@
from megatron.bridge.utils.common_utils import print_rank_0


def is_dataset_built_on_rank(pg_collection: ProcessGroupCollection) -> bool:
def is_dataset_built_on_rank(pg_collection: ProcessGroupCollection | None = None) -> bool:
"""Determines whether the dataset should be built on the current rank.

Datasets are typically built only on the first and last pipeline stages
and the first tensor parallel rank to save memory and avoid redundancy.

Args:
pg_collection: Process group collection. When provided, uses the
explicit process groups. When ``None``, falls back to the global
parallel state, which allows this function to be passed directly
as a zero-argument callable to ``BlendedMegatronDatasetBuilder``.

Returns:
True if the dataset should be built on the current rank, False otherwise.
"""
return (is_pp_first_stage(pg_collection.pp) or is_pp_last_stage(pg_collection.pp)) and (
pg_collection.tp.rank() == 0
if pg_collection is not None:
return (is_pp_first_stage(pg_collection.pp) or is_pp_last_stage(pg_collection.pp)) and (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check if rank contains MTP layer, required to support placing MTP layers into standalone stages (Not the last PP stage)

https://github.com/NVIDIA/Megatron-LM/blob/3d1a4ba71ecc49f1a0c9480c90f819d2b00f9915/pretrain_gpt.py#L209

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added

pg_collection.tp.rank() == 0
)
return (parallel_state.is_pipeline_first_stage() or parallel_state.is_pipeline_last_stage()) and (
parallel_state.get_tensor_model_parallel_rank() == 0
)


Expand Down Expand Up @@ -76,9 +89,11 @@ def pretrain_train_valid_test_datasets_provider(

print_rank_0("> building train, validation, and test datasets for GPT ...")

# Build the dataset on all ranks for TP-replicated loading
broadcast_data = getattr(dataset_config, "broadcast_data_across_tp", False)
is_built_on_rank = is_dataset_built_on_rank if broadcast_data else (lambda: True)

train_ds, valid_ds, test_ds = BlendedMegatronDatasetBuilder(
dataset_type, train_val_test_num_samples, lambda: True, dataset_config
dataset_type, train_val_test_num_samples, is_built_on_rank, dataset_config
).build()

print_rank_0("> finished creating GPT datasets ...")
Expand Down
9 changes: 9 additions & 0 deletions src/megatron/bridge/training/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,15 @@ class DataloaderConfig:
trust_remote_code: Optional[bool] = None
"""Whether remote code execution should be trusted for a given HF path."""

broadcast_data_across_tp: bool = False
"""When True, only TP-rank-0 loads data and broadcasts to other TP ranks.

This eliminates redundant I/O across tensor-parallel ranks and is critical
for storage backends with high contention costs (e.g. VAST / network-attached
storage). When False (default), every rank loads data independently, which
works well on low-latency parallel file-systems like Lustre.
"""


@dataclass(frozen=True)
class DatasetBuildContext:
Expand Down
21 changes: 14 additions & 7 deletions src/megatron/bridge/training/gpt_step.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from megatron.bridge.training.losses import masked_next_token_loss
from megatron.bridge.training.post_training.distillation import loss_func_kd
from megatron.bridge.training.state import GlobalState
from megatron.bridge.training.utils.batch_utils import get_batch_on_this_tp_rank
from megatron.bridge.training.utils.packed_seq_utils import get_packed_seq_params
from megatron.bridge.training.utils.pg_utils import get_pg_collection

Expand Down Expand Up @@ -169,13 +170,19 @@ def get_batch(
if (not is_first) and (not is_last):
return None, None, None, None, None, None, None, None, None, None

batch = get_batch_from_iterator(
data_iterator,
use_mtp,
getattr(cfg.dataset, "skip_getting_attention_mask_from_dataset", True),
is_first_pp_stage=is_first,
is_last_pp_stage=is_last,
)
broadcast_data = getattr(cfg.dataset, "broadcast_data_across_tp", False)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added

if broadcast_data:
# TP-rank-0 loads data and broadcasts to other TP ranks.
# Reduces I/O by a factor of TP on high-latency storage.
batch = get_batch_on_this_tp_rank(data_iterator, cfg, use_mtp, pg_collection=pg_collection)
else:
batch = get_batch_from_iterator(
data_iterator,
use_mtp,
getattr(cfg.dataset, "skip_getting_attention_mask_from_dataset", True),
is_first_pp_stage=is_first,
is_last_pp_stage=is_last,
)

cp_size = pg_collection.cp.size()
has_packed = batch.get("cu_seqlens") is not None
Expand Down
15 changes: 15 additions & 0 deletions src/megatron/bridge/training/utils/batch_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,4 +149,19 @@ def _broadcast(item):
"position_ids": position_ids,
}

# Broadcast any extra keys (e.g. packed-sequence metadata) that are not
# covered by the fixed-shape direct broadcasts above. A lightweight
# boolean flag is broadcast first so the heavy broadcast_object_list call
# is skipped entirely when there are no extra keys (common case).
has_extra = [bool({k for k in data if k not in batch}) if is_tp_rank0 else False]
torch.distributed.broadcast_object_list(has_extra, src=tp_ranks[0], group=tp_group)
if has_extra[0]:
extra = [{k: v for k, v in data.items() if k not in batch} if is_tp_rank0 else None]
torch.distributed.broadcast_object_list(extra, src=tp_ranks[0], group=tp_group)
for key, val in extra[0].items():
if isinstance(val, torch.Tensor):
batch[key] = val.cuda(non_blocking=True)
else:
batch[key] = val

return batch
1 change: 1 addition & 0 deletions tests/benchmarks/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

Loading