ba4be087d5
Create PR to main with cherry-pick from release / cherry-pick (push) Failing after 0s
CICD NeMo / pre-flight (push) Failing after 0s
CICD NeMo / configure (push) Has been skipped
Build, validate, and release Neural Modules / pre-flight (push) Failing after 1s
CICD NeMo / code-linting (push) Has been skipped
Build, validate, and release Neural Modules / release (push) Has been skipped
Build, validate, and release Neural Modules / release-summary (push) Has been cancelled
CICD NeMo / cicd-test-container-build (push) Has been cancelled
CICD NeMo / cicd-import-tests (push) Has been cancelled
CICD NeMo / L0_Setup_Test_Data_And_Models (push) Has been cancelled
CICD NeMo / cicd-main-unit-tests (push) Has been cancelled
CICD NeMo / cicd-main-speech (push) Has been cancelled
CICD NeMo / Nemo_CICD_Test (push) Has been cancelled
CICD NeMo / Coverage (e2e) (push) Has been cancelled
CICD NeMo / Coverage (unit-test) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
CICD NeMo / cicd-wait-in-queue (push) Has been cancelled
403 lines
15 KiB
Python
403 lines
15 KiB
Python
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
|
||
#
|
||
# Licensed 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.
|
||
import os
|
||
from types import SimpleNamespace
|
||
|
||
import pytest
|
||
import torch
|
||
import torch.distributed as dist
|
||
from lhotse import CutSet
|
||
from lhotse.testing.dummies import DummyManifest
|
||
from omegaconf import DictConfig
|
||
|
||
try:
|
||
from torch.distributed._local_tensor import LocalTensorMode # noqa: E402
|
||
except ImportError:
|
||
pytest.skip("Local tensor mode requires PyTorch >= 2.10", allow_module_level=True)
|
||
|
||
from lightning.pytorch.strategies.model_parallel import _setup_device_mesh
|
||
|
||
from nemo.collections.common.data.lhotse import get_lhotse_dataloader_from_config
|
||
from nemo.collections.common.tokenizers.sentencepiece_tokenizer import SentencePieceTokenizer, create_spt_model
|
||
from nemo.collections.speechlm2.data import DataModule
|
||
|
||
nemo_automodel = pytest.importorskip("nemo_automodel")
|
||
from nemo_automodel.components.distributed.config import FSDP2Config # noqa: E402
|
||
|
||
from nemo.collections.speechlm2.parts.parallel import AutomodelParallelStrategy # noqa: E402
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fixtures
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.fixture
|
||
def fake_dist():
|
||
"""Set up minimal env for fake distributed backend, tear down after test."""
|
||
os.environ.setdefault("MASTER_ADDR", "localhost")
|
||
os.environ.setdefault("MASTER_PORT", "0")
|
||
yield
|
||
if dist.is_initialized():
|
||
dist.destroy_process_group()
|
||
|
||
|
||
@pytest.fixture
|
||
def tokenizer(tmp_path_factory):
|
||
tmpdir = tmp_path_factory.mktemp("tok")
|
||
text_path = tmpdir / "text.txt"
|
||
text_path.write_text("\n".join(chr(i) for i in range(256)))
|
||
create_spt_model(
|
||
text_path,
|
||
vocab_size=512,
|
||
sample_size=-1,
|
||
do_lower_case=False,
|
||
output_dir=str(tmpdir),
|
||
bos=True,
|
||
eos=True,
|
||
remove_extra_whitespaces=True,
|
||
)
|
||
return SentencePieceTokenizer(str(tmpdir / "tokenizer.model"))
|
||
|
||
|
||
class Identity(torch.utils.data.Dataset):
|
||
def __getitem__(self, item):
|
||
return item
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _extract_rank_mapping(sym_val, world_size):
|
||
"""Extract ``{global_rank: value}`` mapping from a SymInt produced by LocalTensorMode.
|
||
|
||
Inside ``LocalTensorMode``, ``DeviceMesh.get_local_rank()`` returns a
|
||
``SymInt`` backed by a ``LocalIntNode`` (per-rank varying values) or a
|
||
``ConstantIntNode`` (same value on every rank). Arithmetic on these
|
||
``SymInt`` values preserves the per-rank structure.
|
||
|
||
This helper unwraps the result into a plain dict for easy assertions.
|
||
"""
|
||
if isinstance(sym_val, int):
|
||
return {r: sym_val for r in range(world_size)}
|
||
node = sym_val.node
|
||
if hasattr(node, "_local_ints"):
|
||
return dict(node._local_ints)
|
||
# ConstantIntNode – same value for every rank.
|
||
val = node.maybe_as_int() if hasattr(node, "maybe_as_int") else node.val
|
||
return {r: val for r in range(world_size)}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"pp,dp_rep,dp_shard,cp,tp",
|
||
[
|
||
(1, 1, 1, 1, 1),
|
||
(1, 2, 2, 1, 1),
|
||
(1, 1, 2, 1, 1),
|
||
(2, 3, 2, 1, 2),
|
||
(2, 2, 4, 2, 2),
|
||
],
|
||
)
|
||
def test_dp_rank_via_strategy(fake_dist, pp, dp_rep, dp_shard, cp, tp):
|
||
"""Verify DataModule DP rank using the real AutomodelParallelStrategy.
|
||
|
||
Uses ``LocalTensorMode`` to obtain per-rank symbolic results for every
|
||
global rank in a single process and checks that:
|
||
|
||
* The DataModule rank agrees with the strategy's flattened ``"dp"`` submesh.
|
||
* The correct number of unique DP ranks exists.
|
||
* All DP ranks are in ``[0, dp_size)``.
|
||
* Each DP rank covers exactly the non-DP ranks assigned to it.
|
||
"""
|
||
dp_size = dp_rep * dp_shard
|
||
world_size = pp * dp_size * cp * tp
|
||
dist.init_process_group(backend="fake", rank=0, world_size=world_size)
|
||
|
||
strategy = AutomodelParallelStrategy(
|
||
pp_size=pp,
|
||
tp_size=tp,
|
||
cp_size=cp,
|
||
dp_size=dp_size,
|
||
dp_replicate_size=dp_rep,
|
||
distributed_config=FSDP2Config(backend="gloo"),
|
||
)
|
||
|
||
with LocalTensorMode(world_size):
|
||
device_mesh, _ = strategy.create_device_mesh()
|
||
|
||
data = DataModule(DictConfig({"train_ds": {"batch_size": 2}}), tokenizer=None, dataset=Identity())
|
||
data.trainer = SimpleNamespace(model=SimpleNamespace(device_mesh=device_mesh))
|
||
|
||
dp_rank_data = data._get_dp_rank()
|
||
dp_world_size = data._get_world_size()
|
||
sampler_kwargs = strategy.distributed_sampler_kwargs
|
||
|
||
assert dp_world_size == dp_size
|
||
assert sampler_kwargs["num_replicas"] == dp_size
|
||
|
||
data_rank = _extract_rank_mapping(dp_rank_data, world_size)
|
||
sampler_rank = _extract_rank_mapping(sampler_kwargs["rank"], world_size)
|
||
|
||
# DataModule and strategy sampler rank must agree for every global rank.
|
||
assert data_rank == sampler_rank
|
||
|
||
# Correct number of unique dp ranks.
|
||
assert len(set(data_rank.values())) == dp_size
|
||
|
||
# All dp_ranks in valid range.
|
||
assert all(0 <= v < dp_size for v in data_rank.values())
|
||
|
||
ranks_per_dp = {dp_rank: [] for dp_rank in range(dp_size)}
|
||
for rank, dp_rank in data_rank.items():
|
||
ranks_per_dp[dp_rank].append(rank)
|
||
assert all(len(ranks) == pp * cp * tp for ranks in ranks_per_dp.values())
|
||
|
||
|
||
def test_non_dp_dims_share_dp_rank(fake_dist):
|
||
"""Ranks that differ only in pp / tp / cp get the same dp_rank."""
|
||
pp, dp_rep, dp_shard, cp, tp = 2, 3, 2, 1, 2
|
||
dp_size = dp_rep * dp_shard
|
||
world_size = pp * dp_size * cp * tp
|
||
dist.init_process_group(backend="fake", rank=0, world_size=world_size)
|
||
|
||
with LocalTensorMode(world_size):
|
||
strategy = AutomodelParallelStrategy(
|
||
pp_size=pp,
|
||
tp_size=tp,
|
||
cp_size=cp,
|
||
dp_size=dp_size,
|
||
dp_replicate_size=dp_rep,
|
||
distributed_config=FSDP2Config(backend="gloo"),
|
||
)
|
||
device_mesh, _ = strategy.create_device_mesh()
|
||
|
||
data = DataModule(DictConfig({"train_ds": {"batch_size": 2}}), tokenizer=None, dataset=Identity())
|
||
data.trainer = SimpleNamespace(model=SimpleNamespace(device_mesh=device_mesh))
|
||
dp_rank_sym = data._get_dp_rank()
|
||
|
||
rank_to_dp = _extract_rank_mapping(dp_rank_sym, world_size)
|
||
|
||
# Mesh layout: (pp=2, dp_rep=3, dp_shard=2, cp=1, tp=2)
|
||
# Rank 0: (pp=0, dp_rep=0, dp_shard=0, cp=0, tp=0)
|
||
# Rank 1: (pp=0, dp_rep=0, dp_shard=0, cp=0, tp=1) -- differs in TP
|
||
# Rank 12: (pp=1, dp_rep=0, dp_shard=0, cp=0, tp=0) -- differs in PP
|
||
# Rank 4: (pp=0, dp_rep=1, dp_shard=0, cp=0, tp=0) -- differs in dp_replicate
|
||
# Rank 2: (pp=0, dp_rep=0, dp_shard=1, cp=0, tp=0) -- differs in dp_shard
|
||
|
||
# Same DP coordinates, different non-DP dims → same dp_rank
|
||
assert rank_to_dp[0] == rank_to_dp[1], "TP variation should not change dp_rank"
|
||
assert rank_to_dp[0] == rank_to_dp[12], "PP variation should not change dp_rank"
|
||
|
||
# Different DP coordinates → different dp_rank
|
||
assert rank_to_dp[0] != rank_to_dp[4], "dp_replicate variation must change dp_rank"
|
||
assert rank_to_dp[0] != rank_to_dp[2], "dp_shard variation must change dp_rank"
|
||
|
||
|
||
def test_datamodule_get_dp_rank_automodel(fake_dist, tokenizer):
|
||
"""DataModule._get_dp_rank() / _get_world_size() return correct values."""
|
||
pp, dp_rep, dp_shard, cp, tp = 2, 3, 2, 1, 2
|
||
dp_size = dp_rep * dp_shard
|
||
world_size = pp * dp_size * cp * tp
|
||
dist.init_process_group(backend="fake", rank=0, world_size=world_size)
|
||
|
||
strategy = AutomodelParallelStrategy(
|
||
pp_size=pp,
|
||
tp_size=tp,
|
||
cp_size=cp,
|
||
dp_size=dp_size,
|
||
dp_replicate_size=dp_rep,
|
||
distributed_config=FSDP2Config(backend="gloo"),
|
||
)
|
||
# Create mesh outside LocalTensorMode → real int values for fake rank 0
|
||
device_mesh, _ = strategy.create_device_mesh()
|
||
|
||
cfg = DictConfig({"train_ds": {"batch_size": 2}})
|
||
data = DataModule(cfg, tokenizer=tokenizer, dataset=Identity())
|
||
|
||
# Wire up a mock trainer so _get_dp_rank() can find the device mesh.
|
||
data.trainer = SimpleNamespace(model=SimpleNamespace(device_mesh=device_mesh))
|
||
|
||
dp_rank = data._get_dp_rank()
|
||
dp_ws = data._get_world_size()
|
||
|
||
assert dp_rank is not None, "_get_dp_rank() returned None (missing return?)"
|
||
assert dp_ws is not None, "_get_world_size() returned None (missing return?)"
|
||
assert dp_rank == 0, f"Fake rank 0 should map to dp_rank 0, got {dp_rank}"
|
||
assert dp_ws == dp_size, f"Expected dp_world_size={dp_size}, got {dp_ws}"
|
||
|
||
|
||
def test_dataloader_data_partitioning(tmp_path, tokenizer):
|
||
"""Different dp_ranks get disjoint data; same dp_rank is deterministic."""
|
||
dp_world_size = 6
|
||
num_cuts = 24
|
||
cuts_path = str(tmp_path / "cuts.jsonl.gz")
|
||
(
|
||
DummyManifest(CutSet, begin_id=0, end_id=num_cuts, with_data=True)
|
||
.save_audios(tmp_path / "audio")
|
||
.drop_in_memory_data()
|
||
.to_file(cuts_path)
|
||
)
|
||
|
||
cfg = DictConfig(
|
||
{
|
||
"input_cfg": [{"type": "lhotse", "cuts_path": cuts_path}],
|
||
"batch_size": 2,
|
||
"force_finite": True,
|
||
"force_map_dataset": True,
|
||
"seed": 0,
|
||
"num_workers": 0,
|
||
}
|
||
)
|
||
|
||
# Collect cut IDs from each dp_rank.
|
||
ids_per_rank = {}
|
||
for dp_rank in range(dp_world_size):
|
||
dl = get_lhotse_dataloader_from_config(
|
||
config=cfg,
|
||
global_rank=dp_rank,
|
||
world_size=dp_world_size,
|
||
dataset=Identity(),
|
||
tokenizer=tokenizer,
|
||
)
|
||
ids_per_rank[dp_rank] = [c.id for batch in dl for c in batch]
|
||
|
||
# Different DP ranks must receive disjoint cuts.
|
||
for r1 in range(dp_world_size):
|
||
for r2 in range(r1 + 1, dp_world_size):
|
||
overlap = set(ids_per_rank[r1]) & set(ids_per_rank[r2])
|
||
assert not overlap, f"Ranks {r1} and {r2} share cut IDs: {overlap}"
|
||
|
||
# Union of all ranks covers the full dataset.
|
||
all_ids = set()
|
||
for ids in ids_per_rank.values():
|
||
all_ids.update(ids)
|
||
assert len(all_ids) == num_cuts
|
||
|
||
# Same dp_rank called again → identical sequence (deterministic).
|
||
for dp_rank in (0, dp_world_size - 1):
|
||
dl_again = get_lhotse_dataloader_from_config(
|
||
config=cfg,
|
||
global_rank=dp_rank,
|
||
world_size=dp_world_size,
|
||
dataset=Identity(),
|
||
tokenizer=tokenizer,
|
||
)
|
||
ids_again = [c.id for batch in dl_again for c in batch]
|
||
assert ids_per_rank[dp_rank] == ids_again, f"dp_rank={dp_rank} was not deterministic"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Lightning ModelParallelStrategy tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"dp_size,tp_size",
|
||
[
|
||
(1, 1),
|
||
(1, 4),
|
||
(4, 1),
|
||
(3, 4),
|
||
(6, 2),
|
||
],
|
||
)
|
||
def test_dp_rank_via_lightning_model_parallel(fake_dist, dp_size, tp_size):
|
||
"""Verify DP rank using Lightning's ``_setup_device_mesh``.
|
||
|
||
Lightning's ``ModelParallelStrategy`` creates a 2D mesh with dimensions
|
||
``("data_parallel", "tensor_parallel")``. The DataModule reads DP rank
|
||
via ``dm["data_parallel"].get_local_rank()``.
|
||
|
||
Uses ``LocalTensorMode`` to verify every global rank in one process.
|
||
"""
|
||
world_size = dp_size * tp_size
|
||
dist.init_process_group(backend="fake", rank=0, world_size=world_size)
|
||
|
||
with LocalTensorMode(world_size):
|
||
device_mesh = _setup_device_mesh(dp_size, tp_size, world_size, torch.device("cpu"))
|
||
|
||
dp_rank_sym = device_mesh["data_parallel"].get_local_rank()
|
||
dp_world_size = device_mesh["data_parallel"].size()
|
||
|
||
assert dp_world_size == dp_size
|
||
|
||
rank_to_dp = _extract_rank_mapping(dp_rank_sym, world_size)
|
||
|
||
# Correct number of unique dp ranks.
|
||
assert len(set(rank_to_dp.values())) == dp_size
|
||
|
||
# All dp_ranks in valid range.
|
||
assert all(0 <= v < dp_size for v in rank_to_dp.values())
|
||
|
||
# Ranks sharing the same data_parallel coordinate must have the same
|
||
# dp_rank; different coordinates must differ.
|
||
mesh_tensor = torch.arange(world_size).reshape(dp_size, tp_size)
|
||
for dp_coord in range(dp_size):
|
||
ranks_in_group = mesh_tensor[dp_coord, :].flatten().tolist()
|
||
dp_ranks_in_group = {rank_to_dp[r] for r in ranks_in_group}
|
||
assert (
|
||
len(dp_ranks_in_group) == 1
|
||
), f"DP group (data_parallel={dp_coord}) maps to multiple dp_ranks: {dp_ranks_in_group}"
|
||
|
||
|
||
def test_lightning_tp_variation_does_not_change_dp_rank(fake_dist):
|
||
"""Ranks that differ only in tensor_parallel get the same dp_rank."""
|
||
dp_size, tp_size = 3, 4
|
||
world_size = dp_size * tp_size
|
||
dist.init_process_group(backend="fake", rank=0, world_size=world_size)
|
||
|
||
with LocalTensorMode(world_size):
|
||
device_mesh = _setup_device_mesh(dp_size, tp_size, world_size, torch.device("cpu"))
|
||
dp_rank_sym = device_mesh["data_parallel"].get_local_rank()
|
||
|
||
rank_to_dp = _extract_rank_mapping(dp_rank_sym, world_size)
|
||
|
||
# Mesh layout: (dp=3, tp=4)
|
||
# Rank 0: (dp=0, tp=0)
|
||
# Rank 1: (dp=0, tp=1) -- differs only in TP
|
||
# Rank 4: (dp=1, tp=0) -- differs in DP
|
||
|
||
assert rank_to_dp[0] == rank_to_dp[1], "TP variation should not change dp_rank"
|
||
assert rank_to_dp[0] == rank_to_dp[2], "TP variation should not change dp_rank"
|
||
assert rank_to_dp[0] == rank_to_dp[3], "TP variation should not change dp_rank"
|
||
assert rank_to_dp[0] != rank_to_dp[4], "DP variation must change dp_rank"
|
||
|
||
|
||
def test_datamodule_get_dp_rank_lightning_model_parallel(fake_dist, tokenizer):
|
||
"""DataModule._get_dp_rank() / _get_world_size() with Lightning's 2D mesh."""
|
||
dp_size, tp_size = 3, 4
|
||
world_size = dp_size * tp_size
|
||
dist.init_process_group(backend="fake", rank=0, world_size=world_size)
|
||
|
||
# Create mesh outside LocalTensorMode → real int values for fake rank 0
|
||
device_mesh = _setup_device_mesh(dp_size, tp_size, world_size, torch.device("cpu"))
|
||
|
||
cfg = DictConfig({"train_ds": {"batch_size": 2}})
|
||
data = DataModule(cfg, tokenizer=tokenizer, dataset=Identity())
|
||
data.trainer = SimpleNamespace(model=SimpleNamespace(device_mesh=device_mesh))
|
||
|
||
dp_rank = data._get_dp_rank()
|
||
dp_ws = data._get_world_size()
|
||
|
||
assert dp_rank is not None, "_get_dp_rank() returned None (missing return?)"
|
||
assert dp_ws is not None, "_get_world_size() returned None (missing return?)"
|
||
assert dp_rank == 0, f"Fake rank 0 should map to dp_rank 0, got {dp_rank}"
|
||
assert dp_ws == dp_size, f"Expected dp_world_size={dp_size}, got {dp_ws}"
|